"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Stefan Sperling <stsp@stsp.name>
Subject:
Re: gotd protected references
To:
gameoftrees@openbsd.org
Date:
Tue, 4 Apr 2023 13:31:07 +0200

Download raw body.

Thread
On Fri, Mar 31, 2023 at 10:34:26PM +0200, Stefan Sperling wrote:
> On Fri, Mar 31, 2023 at 10:06:23PM +0200, Stefan Sperling wrote:
> > This patch adds a "protect" directive to gotd.conf which can be used
> > to forbid 'got send -f' on selected branches/tags.
> > 
> > There will always be accidents with 'got send -f' at some point in a
> > project's life cycle so gotd should provide safeguards against this.
> 
> Oh, there is stupid mistake in here. Disregard this for now.
> 
> The patch works fine for references which are not supposed to ever change,
> like tags. But for branches we need a linear ancestry check and only forbid
> the ref-update when the history being pushed is not linear. This will require
> more work to get right.

Fixed diff below.

The config syntax has changed since branches and tags need to follow
slightly different rules when protected.

We now also ensure that new refs created in a protected namspace are of
the appropriate object type. E.g. it will not longer be possible to add
non-tag objects under "refs/tags" if it is a protected tag namespace.

-----------------------------------------------
 add support for protecting references against 'got send -f' to gotd
 
diff 20a2922ac9672923f2aa124d670cde69d69b0cc3 61cb9f46abb7379f50600aea885b049f10cf105d
commit - 20a2922ac9672923f2aa124d670cde69d69b0cc3
commit + 61cb9f46abb7379f50600aea885b049f10cf105d
blob - 38e4349026233f676c860654e1ebed2cc32c13f3
blob + 02cc7ea7b999fff68dd0fd76a0015622e5c99b68
--- gotctl/gotctl.c
+++ gotctl/gotctl.c
@@ -33,6 +33,7 @@
 
 #include "got_error.h"
 #include "got_version.h"
+#include "got_path.h"
 
 #include "got_lib_gitproto.h"
 
blob - 794c48b1569cbd4214a7d34d9e2e81ac05ff789c
blob + 886f92ecc38e22a85b9de751ac0be7ba7d14d98b
--- gotd/gotd.c
+++ gotd/gotd.c
@@ -1700,6 +1700,7 @@ main(int argc, char **argv)
 	enum gotd_procid proc_id = PROC_GOTD;
 	struct event evsigint, evsigterm, evsighup, evsigusr1;
 	int *pack_fds = NULL, *temp_fds = NULL;
+	struct gotd_repo *repo = NULL;
 
 	log_init(1, LOG_DAEMON); /* Log to stderr until daemonized. */
 
@@ -1897,7 +1898,13 @@ main(int argc, char **argv)
 			err(1, "pledge");
 #endif
 		apply_unveil_repo_readonly(repo_path);
-		repo_write_main(title, repo_path, pack_fds, temp_fds);
+		repo = gotd_find_repo_by_path(repo_path, &gotd);
+		if (repo == NULL)
+			fatalx("no repository for path %s", repo_path);
+		repo_write_main(title, repo_path, pack_fds, temp_fds,
+		    &repo->protected_tag_namespaces,
+		    &repo->protected_branch_namespaces,
+		    &repo->protected_branches);
 		/* NOTREACHED */
 		exit(0);
 	default:
blob - 42253ba60317aec22ed6f18dd8917d823ce845ed
blob + 09928aa29395cb1acfaff6303c26cda1adfaf34a
--- gotd/gotd.conf.5
+++ gotd/gotd.conf.5
@@ -172,7 +172,72 @@ Numeric IDs are also accepted.
 to
 .Ar identity .
 Numeric IDs are also accepted.
+.It Ic protect Brq Ar ...
+The
+.Cm protect
+directive may be used to protect branches and tags in a repository
+from being overwritten by potentially destructive client-side commands,
+such as when
+.Cm got send -f
+and
+.Cm git push -f
+are used to change the history of a branch.
+.Pp
+To build a set of protected branches and tags, multiple
+.Ic protect
+directives may be specified per repository and
+multiple
+.Ic protect
+directive parameters may be specified within curly braces.
+.Pp
+The available
+.Cm protect
+parameters are as follows:
+.Pp
+.Bl -tag -width Ds
+.It Ic branch Ar name
+Protect the named branch.
+The branch may be created if it does not exist yet.
+Attempts to delete the branch or change its history will be denied.
+.Pp
+If the
+.Ar name
+does not already begin with
+.Dq refs/heads/
+it will be looked up in the
+.Dq refs/heads/
+reference namespace.
+.It Ic branch Ic namespace Ar namespace
+Protect the given reference namespace, assuming that references in
+this namespace represent branches.
+New branches may be created in the namespace.
+Attempts to change the history of branches or delete them will be denied.
+.Pp
+The
+.Ar namespace
+argument must be absolute, starting with
+.Dq refs/ .
+.It Ic tag Ic namespace Ar namespace
+Protect the given reference namespace, assuming that references in
+this namespace represent tags.
+New tags may be created in the namespace.
+Attempts to change or delete existing tags will be denied.
+.Pp
+The 
+.Ar namespace
+argument must be absolute, starting with
+.Dq refs/ .
 .El
+.Pp
+The special reference namespaces
+.Dq refs/got/
+and
+.Dq refs/remotes/
+do not need to be listed in
+.Nm .
+These namespaces are always protected and even attempts to create new
+references in these namespaces will always be denied.
+.El
 .Sh FILES
 .Bl -tag -width Ds -compact
 .It Pa /etc/gotd.conf
@@ -194,6 +259,9 @@ repository "src" {
 	permit rw flan_hacker
 	permit rw :developers
 	permit ro anonymous
+
+	protect branch "main"
+	protect tag namespace "refs/tags/"
 }
 
 # This repository can be accessed via
@@ -203,6 +271,11 @@ repository "openbsd/ports" {
 	permit rw :porters
 	permit ro anonymous
 	deny flan_hacker
+
+	protect {
+		branch "main"
+		tag namespace "refs/tags/"
+	}
 }
 
 # Use a larger request timeout value:
blob - 1f9b40a97a0e8ed68f190efc0abc407e5fbc5fde
blob + 6453c5edd60a936a27ff6c705694973e711adb82
--- gotd/gotd.h
+++ gotd/gotd.h
@@ -85,6 +85,9 @@ struct gotd_repo {
 	char path[PATH_MAX];
 
 	struct gotd_access_rule_list rules;
+	struct got_pathlist_head protected_tag_namespaces;
+	struct got_pathlist_head protected_branch_namespaces;
+	struct got_pathlist_head protected_branches;
 };
 TAILQ_HEAD(gotd_repolist, gotd_repo);
 
@@ -448,6 +451,7 @@ struct gotd_repo *gotd_find_repo_by_name(const char *,
 
 int parse_config(const char *, enum gotd_procid, struct gotd *, int);
 struct gotd_repo *gotd_find_repo_by_name(const char *, struct gotd *);
+struct gotd_repo *gotd_find_repo_by_path(const char *, struct gotd *);
 
 /* imsg.c */
 const struct got_error *gotd_imsg_flush(struct imsgbuf *);
blob - 3813dd6071aae8355cf41ade517cd479db0cd5af
blob + 86b16615f22802ae9f3a4f249265458eb1392651
--- gotd/imsg.c
+++ gotd/imsg.c
@@ -30,6 +30,7 @@
 #include <unistd.h>
 
 #include "got_error.h"
+#include "got_path.h"
 
 #include "got_lib_poll.h"
 
blob - a0953584fa86b2eef4d45cc9ccb6d5823eb55f00
blob + b51f18490312837f6627991cf323dc1e5bc2d7a6
--- gotd/listen.c
+++ gotd/listen.c
@@ -34,6 +34,7 @@
 #include <unistd.h>
 
 #include "got_error.h"
+#include "got_path.h"
 
 #include "gotd.h"
 #include "log.h"
blob - b7d7e708dc1650571a862cb8f93f26798d1f1037
blob + 3b6daa1a2a93121b0ee884ddd8cd1ceb5e85fa9b
--- gotd/parse.y
+++ gotd/parse.y
@@ -44,6 +44,7 @@
 
 #include "got_error.h"
 #include "got_path.h"
+#include "got_reference.h"
 
 #include "log.h"
 #include "gotd.h"
@@ -92,6 +93,14 @@ static enum gotd_procid		 gotd_proc_id;
 static struct gotd_repo		*conf_new_repo(const char *);
 static void			 conf_new_access_rule(struct gotd_repo *,
 				    enum gotd_access, int, char *);
+static int			 conf_protect_ref_namespace(
+				    struct got_pathlist_head *, char *);
+static int			 conf_protect_tag_namespace(struct gotd_repo *,
+				    char *);
+static int			 conf_protect_branch_namespace(
+				    struct gotd_repo *, char *);
+static int			 conf_protect_branch(struct gotd_repo *,
+				    char *);
 static enum gotd_procid		 gotd_proc_id;
 
 typedef struct {
@@ -107,6 +116,7 @@ typedef struct {
 
 %token	PATH ERROR LISTEN ON USER REPOSITORY PERMIT DENY
 %token	RO RW CONNECTION LIMIT REQUEST TIMEOUT
+%token	PROTECT NAMESPACE BRANCH TAG
 
 %token	<v.string>	STRING
 %token	<v.number>	NUMBER
@@ -229,6 +239,44 @@ repository	: REPOSITORY STRING {
 		}
 		;
 
+protect		: PROTECT '{' optnl protectflags_l '}'
+		| PROTECT protectflags
+
+protectflags_l	: protectflags optnl protectflags_l
+		| protectflags optnl
+		;
+
+protectflags	: TAG NAMESPACE STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_REPO_WRITE) {
+				if (conf_protect_tag_namespace(new_repo, $3)) {
+					free($3);
+					YYERROR;
+				}
+			}
+		}
+		| BRANCH NAMESPACE STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_REPO_WRITE) {
+				if (conf_protect_branch_namespace(new_repo,
+				    $3)) {
+					free($3);
+					YYERROR;
+				}
+				free($3);
+			}
+		}
+		| BRANCH STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_REPO_WRITE) {
+				if (conf_protect_branch(new_repo, $2)) {
+					free($2);
+					YYERROR;
+				}
+			}
+		}
+		;
+
 repository	: REPOSITORY STRING {
 			struct gotd_repo *repo;
 
@@ -241,7 +289,8 @@ repository	: REPOSITORY STRING {
 			}
 
 			if (gotd_proc_id == PROC_GOTD ||
-			    gotd_proc_id == PROC_AUTH) {
+			    gotd_proc_id == PROC_AUTH ||
+			    gotd_proc_id == PROC_REPO_WRITE) {
 				new_repo = conf_new_repo($2);
 			}
 			free($2);
@@ -251,7 +300,8 @@ repoopts1	: PATH STRING {
 
 repoopts1	: PATH STRING {
 			if (gotd_proc_id == PROC_GOTD ||
-			    gotd_proc_id == PROC_AUTH) {
+			    gotd_proc_id == PROC_AUTH ||
+			    gotd_proc_id == PROC_REPO_WRITE) {
 				if (!got_path_is_absolute($2)) {
 					yyerror("%s: path %s is not absolute",
 					    __func__, $2);
@@ -285,6 +335,7 @@ repoopts1	: PATH STRING {
 				    GOTD_ACCESS_DENIED, 0, $2);
 			}
 		}
+		| protect
 		;
 
 repoopts2	: repoopts2 repoopts1 nl
@@ -332,17 +383,21 @@ lookup(char *s)
 {
 	/* This has to be sorted always. */
 	static const struct keywords keywords[] = {
+		{ "branch",			BRANCH },
 		{ "connection",			CONNECTION },
 		{ "deny",			DENY },
 		{ "limit",			LIMIT },
 		{ "listen",			LISTEN },
+		{ "namespace",			NAMESPACE },
 		{ "on",				ON },
 		{ "path",			PATH },
 		{ "permit",			PERMIT },
+		{ "protect",			PROTECT },
 		{ "repository",			REPOSITORY },
 		{ "request",			REQUEST },
 		{ "ro",				RO },
 		{ "rw",				RW },
+		{ "tag",			TAG },
 		{ "timeout",			TIMEOUT },
 		{ "user",			USER },
 	};
@@ -811,6 +866,9 @@ conf_new_repo(const char *name)
 		fatalx("%s: calloc", __func__);
 
 	STAILQ_INIT(&repo->rules);
+	TAILQ_INIT(&repo->protected_tag_namespaces);
+	TAILQ_INIT(&repo->protected_branch_namespaces);
+	TAILQ_INIT(&repo->protected_branches);
 
 	if (strlcpy(repo->name, name, sizeof(repo->name)) >=
 	    sizeof(repo->name))
@@ -839,6 +897,94 @@ int
 	STAILQ_INSERT_TAIL(&repo->rules, rule, entry);
 }
 
+static int
+refname_is_valid(char *refname)
+{
+	if (strlen(refname) < 5 || strncmp(refname, "refs/", 5) != 0) {
+		yyerror("reference name must begin with \"refs/\": %s",
+		    refname);
+		return 0;
+	}
+
+	if (!got_ref_name_is_valid(refname)) {
+		yyerror("invalid reference name: %s", refname);
+		return 0;
+	}
+
+	return 1;
+}
+
+static int
+conf_protect_ref_namespace(struct got_pathlist_head *refs, char *namespace)
+{
+	const struct got_error *error;
+	char *s;
+
+	got_path_strip_trailing_slashes(namespace);
+	if (!refname_is_valid(namespace))
+		return -1;
+	if (asprintf(&s, "%s/", namespace) == -1) {
+		yyerror("asprintf: %s", strerror(errno));
+		return -1;
+	}
+
+	error = got_pathlist_insert(NULL, refs, s, NULL);
+	if (error) {
+		yyerror("got_pathlist_insert: %s", error->msg);
+		return -1;
+	}
+
+	return 0;
+}
+
+static int
+conf_protect_tag_namespace(struct gotd_repo *repo, char *namespace)
+{
+	return conf_protect_ref_namespace(&repo->protected_tag_namespaces,
+	    namespace);
+}
+
+static int
+conf_protect_branch_namespace(struct gotd_repo *repo, char *namespace)
+{
+	return conf_protect_ref_namespace(&repo->protected_branch_namespaces,
+	    namespace);
+}
+
+static int
+conf_protect_branch(struct gotd_repo *repo, char *branchname)
+{
+	const struct got_error *error;
+	char *refname;
+
+	if (strncmp(branchname, "refs/heads/", 11) != 0) {
+		if (asprintf(&refname, "refs/heads/%s", branchname) == -1) {
+			yyerror("asprintf: %s", strerror(errno));
+			return -1;
+		}
+	} else {
+		refname = strdup(branchname);
+		if (refname == NULL) {
+			yyerror("strdup: %s", strerror(errno));
+			return -1;
+		}
+	}
+
+	if (!refname_is_valid(refname)) {
+		free(refname);
+		return -1;
+	}
+
+	error = got_pathlist_insert(NULL, &repo->protected_branches,
+	    refname, NULL);
+	if (error) {
+		yyerror("got_pathlist_insert: %s", error->msg);
+		return -1;
+	}
+
+	return 0;
+}
+
 int
 symset(const char *nam, const char *val, int persist)
 {
@@ -911,3 +1057,16 @@ gotd_find_repo_by_name(const char *repo_name, struct g
 
 	return NULL;
 }
+
+struct gotd_repo *
+gotd_find_repo_by_path(const char *repo_path, struct gotd *gotd)
+{
+	struct gotd_repo *repo;
+
+	TAILQ_FOREACH(repo, &gotd->repos, entry) {
+		if (strcmp(repo->path, repo_path) == 0)
+			return repo;
+	}
+
+	return NULL;
+}
blob - a4d19987c878bbc0be5a08026bc1ba88bf3a3f11
blob + 77af3223695f08a4c263ad27a9d0d0333b0307e6
--- gotd/repo_imsg.c
+++ gotd/repo_imsg.c
@@ -30,6 +30,7 @@
 
 #include "got_error.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "got_lib_hash.h"
 
blob - 7775b1f5018ccb890406b37a3815e460b7cb8244
blob + b038586cc341ac1f637b059595471f402c3a54aa
--- gotd/repo_read.c
+++ gotd/repo_read.c
@@ -37,6 +37,7 @@
 #include "got_repository.h"
 #include "got_reference.h"
 #include "got_repository_admin.h"
+#include "got_path.h"
 
 #include "got_lib_delta.h"
 #include "got_lib_object.h"
blob - 558b5bdb3d78eb3e3f997f05a681c61ec2f73414
blob + 317d11a2dde1181dd8dca488ba5ed7f353895c70
--- gotd/repo_write.c
+++ gotd/repo_write.c
@@ -47,6 +47,8 @@
 #include "got_lib_hash.h"
 #include "got_lib_object.h"
 #include "got_lib_object_cache.h"
+#include "got_lib_object_idset.h"
+#include "got_lib_object_parse.h"
 #include "got_lib_ratelimit.h"
 #include "got_lib_pack.h"
 #include "got_lib_pack_index.h"
@@ -69,6 +71,9 @@ static struct repo_write {
 	int *temp_fds;
 	int session_fd;
 	struct gotd_imsgev session_iev;
+	struct got_pathlist_head *protected_tag_namespaces;
+	struct got_pathlist_head *protected_branch_namespaces;
+	struct got_pathlist_head *protected_branches;
 } repo_write;
 
 struct gotd_ref_update {
@@ -312,7 +317,7 @@ protect_ref_namespace(struct got_reference *ref, const
 }
 
 static const struct got_error *
-protect_ref_namespace(struct got_reference *ref, const char *namespace)
+validate_namespace(const char *namespace)
 {
 	size_t len = strlen(namespace);
 
@@ -322,13 +327,279 @@ protect_ref_namespace(struct got_reference *ref, const
 		    "reference namespace '%s'", namespace);
 	}
 
-	if (strncmp(namespace, got_ref_get_name(ref), len) == 0)
+	return NULL;
+}
+
+static const struct got_error *
+protect_ref_namespace(const char *refname, const char *namespace)
+{
+	const struct got_error *err;
+
+	err = validate_namespace(namespace);
+	if (err)
+		return err;
+
+	if (strncmp(namespace, refname, strlen(namespace)) == 0)
 		return got_error_fmt(GOT_ERR_REFS_PROTECTED, "%s", namespace);
 
 	return NULL;
 }
 
 static const struct got_error *
+verify_object_type(struct got_object_id *id, int expected_obj_type,
+    struct got_pack *pack, struct got_packidx *packidx)
+{
+	const struct got_error *err;
+	char hex[SHA1_DIGEST_STRING_LENGTH];
+	struct got_object *obj;
+	int idx;
+	const char *typestr;
+
+	idx = got_packidx_get_object_idx(packidx, id);
+	if (idx == -1) {
+		got_sha1_digest_to_str(id->sha1, hex, sizeof(hex));
+		return got_error_fmt(GOT_ERR_BAD_PACKFILE,
+		    "object %s is missing from pack file", hex);
+	}
+
+	err = got_object_open_from_packfile(&obj, id, pack, packidx,
+	    idx, repo_write.repo);
+	if (err)
+		return err;
+
+	if (obj->type != expected_obj_type) {
+		got_sha1_digest_to_str(id->sha1, hex, sizeof(hex));
+		got_object_type_label(&typestr, expected_obj_type);
+		err = got_error_fmt(GOT_ERR_OBJ_TYPE,
+		    "%s is not pointing at a %s object", hex, typestr);
+	}
+	got_object_close(obj);
+	return err;
+}
+
+static const struct got_error *
+protect_tag_namespace(const char *namespace, struct got_pack *pack,
+    struct got_packidx *packidx, struct gotd_ref_update *ref_update)
+{
+	const struct got_error *err;
+
+	err = validate_namespace(namespace);
+	if (err)
+		return err;
+
+	if (strncmp(namespace, got_ref_get_name(ref_update->ref),
+	    strlen(namespace)) != 0)
+		return NULL;
+
+	if (!ref_update->ref_is_new)
+		return got_error_fmt(GOT_ERR_REFS_PROTECTED, "%s", namespace);
+
+	return verify_object_type(&ref_update->new_id, GOT_OBJ_TYPE_TAG,
+	    pack, packidx);
+}
+
+static const struct got_error *
+protect_require_yca(struct got_object_id *tip_id,
+    size_t max_commits_to_traverse, struct got_pack *pack,
+    struct got_packidx *packidx, struct got_reference *ref)
+{
+	const struct got_error *err;
+	uint8_t *buf = NULL;
+	size_t len;
+	struct got_object_id *expected_yca_id = NULL;
+	struct got_object *obj = NULL;
+	struct got_commit_object *commit = NULL;
+	char hex[SHA1_DIGEST_STRING_LENGTH];
+	const struct got_object_id_queue *parent_ids;
+	struct got_object_id_queue ids;
+	struct got_object_qid *pid, *qid;
+	struct got_object_idset *traversed_set = NULL;
+	int found_yca = 0, obj_type;
+
+	STAILQ_INIT(&ids);
+
+	err = got_ref_resolve(&expected_yca_id, repo_write.repo, ref);
+	if (err)
+		return err;
+	
+	err = got_object_get_type(&obj_type, repo_write.repo, expected_yca_id);
+	if (err)
+		goto done;
+
+	if (obj_type != GOT_OBJ_TYPE_COMMIT) {
+		got_sha1_digest_to_str(expected_yca_id->sha1, hex, sizeof(hex));
+		err = got_error_fmt(GOT_ERR_OBJ_TYPE,
+		    "%s is not pointing at a commit object", hex);
+		goto done;
+	}
+
+	traversed_set = got_object_idset_alloc();
+	if (traversed_set == NULL) {
+		err = got_error_from_errno("got_object_idset_alloc");
+		goto done;
+	}
+
+	err = got_object_qid_alloc(&qid, tip_id);
+	if (err)
+		goto done;
+	STAILQ_INSERT_TAIL(&ids, qid, entry);
+	while (!STAILQ_EMPTY(&ids)) {
+		err = check_cancelled(NULL);
+		if (err)
+			break;
+
+		qid = STAILQ_FIRST(&ids);
+		if (got_object_id_cmp(&qid->id, expected_yca_id) == 0) {
+			found_yca = 1;
+			break;
+		}
+
+		if (got_object_idset_num_elements(traversed_set) >=
+		    max_commits_to_traverse)
+			break;
+
+		if (got_object_idset_contains(traversed_set, &qid->id)) {
+			STAILQ_REMOVE_HEAD(&ids, entry);
+			got_object_qid_free(qid);
+			qid = NULL;
+			continue;
+		}
+		err = got_object_idset_add(traversed_set, &qid->id, NULL);
+		if (err)
+			goto done;
+
+		err = got_object_open(&obj, repo_write.repo, &qid->id);
+		if (err && err->code != GOT_ERR_NO_OBJ)
+			goto done;
+		err = NULL;
+		if (obj) {
+			err = got_object_commit_open(&commit, repo_write.repo,
+			    obj);
+			if (err)
+				goto done;
+		} else {
+			int idx;
+
+			idx = got_packidx_get_object_idx(packidx, &qid->id);
+			if (idx == -1) {
+				got_sha1_digest_to_str(qid->id.sha1,
+				    hex, sizeof(hex));
+				err = got_error_fmt(GOT_ERR_BAD_PACKFILE,
+				    "object %s is missing from pack file", hex);
+				goto done;
+			}
+
+			err = got_object_open_from_packfile(&obj, &qid->id,
+				pack, packidx, idx, repo_write.repo);
+			if (err)
+				goto done;
+
+			if (obj->type != GOT_OBJ_TYPE_COMMIT) {
+				got_sha1_digest_to_str(qid->id.sha1,
+				    hex, sizeof(hex));
+				err = got_error_fmt(GOT_ERR_OBJ_TYPE,
+				    "%s is not pointing at a commit object",
+				    hex);
+				goto done;
+			}
+
+			err = got_packfile_extract_object_to_mem(&buf, &len,
+			    obj, pack);
+			if (err)
+				goto done;
+
+			err = got_object_parse_commit(&commit, buf, len);
+			if (err)
+				goto done;
+
+			free(buf);
+			buf = NULL;
+		}
+
+		got_object_close(obj);
+		obj = NULL;
+
+		STAILQ_REMOVE_HEAD(&ids, entry);
+		got_object_qid_free(qid);
+		qid = NULL;
+
+		if (got_object_commit_get_nparents(commit) == 0)
+			break;
+
+		parent_ids = got_object_commit_get_parent_ids(commit);
+		STAILQ_FOREACH(pid, parent_ids, entry) {
+			err = check_cancelled(NULL);
+			if (err)
+				goto done;
+			err = got_object_qid_alloc(&qid, &pid->id);
+			if (err)
+				goto done;
+			STAILQ_INSERT_TAIL(&ids, qid, entry);
+			qid = NULL;
+		}
+		got_object_commit_close(commit);
+		commit = NULL;
+	}
+
+	if (!found_yca) {
+		err = got_error_fmt(GOT_ERR_REF_PROTECTED, "%s",
+		    got_ref_get_name(ref));
+	}
+done:
+	got_object_idset_free(traversed_set);
+	got_object_id_queue_free(&ids);
+	free(buf);
+	if (obj)
+		got_object_close(obj);
+	if (commit)
+		got_object_commit_close(commit);
+	free(expected_yca_id);
+	return err;
+}
+
+static const struct got_error *
+protect_branch_namespace(const char *namespace, struct got_pack *pack,
+    struct got_packidx *packidx, struct gotd_ref_update *ref_update)
+{
+	const struct got_error *err;
+
+	err = validate_namespace(namespace);
+	if (err)
+		return err;
+
+	if (strncmp(namespace, got_ref_get_name(ref_update->ref),
+	    strlen(namespace)) != 0)
+		return NULL;
+
+	if (ref_update->ref_is_new) {
+		return verify_object_type(&ref_update->new_id,
+		    GOT_OBJ_TYPE_COMMIT, pack, packidx);
+	}
+
+	return protect_require_yca(&ref_update->new_id,
+	    be32toh(packidx->hdr.fanout_table[0xff]), pack, packidx,
+	    ref_update->ref);
+}
+
+static const struct got_error *
+protect_branch(const char *refname, struct got_pack *pack,
+    struct got_packidx *packidx, struct gotd_ref_update *ref_update)
+{
+	if (strcmp(refname, got_ref_get_name(ref_update->ref)) != 0)
+		return NULL;
+
+	/* Always allow new branches to be created. */
+	if (ref_update->ref_is_new) {
+		return verify_object_type(&ref_update->new_id,
+		    GOT_OBJ_TYPE_COMMIT, pack, packidx);
+	}
+
+	return protect_require_yca(&ref_update->new_id,
+	    be32toh(packidx->hdr.fanout_table[0xff]), pack, packidx,
+	    ref_update->ref);
+}
+
+static const struct got_error *
 recv_ref_update(struct imsg *imsg)
 {
 	static const char zero_id[SHA1_DIGEST_LENGTH];
@@ -370,6 +641,12 @@ recv_ref_update(struct imsg *imsg)
 	if (err) {
 		if (err->code != GOT_ERR_NOT_REF)
 			goto done;
+		if (memcmp(ref_update->new_id.sha1,
+		    zero_id, sizeof(zero_id)) == 0) {
+			err = got_error_fmt(GOT_ERR_BAD_OBJ_ID,
+			    "%s", refname);
+			goto done;
+		}
 		err = got_ref_alloc(&ref, refname, &ref_update->new_id);
 		if (err)
 			goto done;
@@ -389,10 +666,10 @@ recv_ref_update(struct imsg *imsg)
 		goto done;
 	}
 
-	err = protect_ref_namespace(ref, "refs/got/");
+	err = protect_ref_namespace(got_ref_get_name(ref), "refs/got/");
 	if (err)
 		goto done;
-	err = protect_ref_namespace(ref, "refs/remotes/");
+	err = protect_ref_namespace(got_ref_get_name(ref), "refs/remotes/");
 	if (err)
 		goto done;
 
@@ -1040,7 +1317,9 @@ verify_packfile(void)
 	struct got_packidx *packidx = NULL;
 	struct stat sb;
 	char *id_str = NULL;
-	int idx = -1;
+	struct got_object *obj = NULL;
+	struct got_pathlist_entry *pe;
+	char hex[SHA1_DIGEST_STRING_LENGTH];
 
 	if (STAILQ_EMPTY(&client->ref_updates)) {
 		return got_error_msg(GOT_ERR_BAD_REQUEST,
@@ -1073,17 +1352,51 @@ verify_packfile(void)
 		if (ref_update->delete_ref)
 			continue;
 
-		err = got_object_id_str(&id_str, &ref_update->new_id);
-		if (err)
-			goto done;
+		TAILQ_FOREACH(pe, repo_write.protected_tag_namespaces, entry) {
+			err = protect_tag_namespace(pe->path, &client->pack,
+			    packidx, ref_update);
+			if (err)
+				goto done;
+		}
 
-		idx = got_packidx_get_object_idx(packidx, &ref_update->new_id);
-		if (idx == -1) {
-			err = got_error_fmt(GOT_ERR_BAD_PACKFILE,
-			    "advertised object %s is missing from pack file",
-			    id_str);
+		/*
+		 * Objects which already exist in our repository need
+		 * not be present in the pack file.
+		 */
+		err = got_object_open(&obj, repo_write.repo,
+		    &ref_update->new_id);
+		if (err && err->code != GOT_ERR_NO_OBJ)
 			goto done;
+		err = NULL;
+		if (obj) {
+			got_object_close(obj);
+			obj = NULL;
+		} else {
+			int idx = got_packidx_get_object_idx(packidx,
+			    &ref_update->new_id);
+			if (idx == -1) {
+				got_sha1_digest_to_str(ref_update->new_id.sha1,
+				    hex, sizeof(hex));
+				err = got_error_fmt(GOT_ERR_BAD_PACKFILE,
+				    "object %s is missing from pack file",
+				    hex);
+				goto done;
+			}
 		}
+
+		TAILQ_FOREACH(pe, repo_write.protected_branch_namespaces,
+		    entry) {
+			err = protect_branch_namespace(pe->path,
+			    &client->pack, packidx, ref_update);
+			if (err)
+				goto done;
+		}
+		TAILQ_FOREACH(pe, repo_write.protected_branches, entry) {
+			err = protect_branch(pe->path, &client->pack,
+			    packidx, ref_update);
+			if (err)
+				goto done;
+		}
 	}
 
 done:
@@ -1091,10 +1404,51 @@ done:
 	if (close_err && err == NULL)
 		err = close_err;
 	free(id_str);
+	if (obj)
+		got_object_close(obj);
 	return err;
 }
 
 static const struct got_error *
+protect_refs_from_deletion(void)
+{
+	const struct got_error *err = NULL;
+	struct repo_write_client *client = &repo_write_client;
+	struct gotd_ref_update *ref_update;
+	struct got_pathlist_entry *pe;
+	const char *refname;
+
+	STAILQ_FOREACH(ref_update, &client->ref_updates, entry) {
+		if (!ref_update->delete_ref)
+			continue;
+
+		refname = got_ref_get_name(ref_update->ref);
+
+		TAILQ_FOREACH(pe, repo_write.protected_tag_namespaces, entry) {
+			err = protect_ref_namespace(refname, pe->path);
+			if (err)
+				return err;
+		}
+
+		TAILQ_FOREACH(pe, repo_write.protected_branch_namespaces,
+		    entry) {
+			err = protect_ref_namespace(refname, pe->path);
+			if (err)
+				return err;
+		}
+
+		TAILQ_FOREACH(pe, repo_write.protected_branches, entry) {
+			if (strcmp(refname, pe->path) == 0) {
+				return got_error_fmt(GOT_ERR_REF_PROTECTED,
+				    "%s", refname);
+			}
+		}
+	}
+
+	return NULL;
+}
+
+static const struct got_error *
 install_packfile(struct gotd_imsgev *iev)
 {
 	struct repo_write_client *client = &repo_write_client;
@@ -1301,6 +1655,9 @@ repo_write_dispatch_session(int fd, short event, void 
 			}
 			break;
 		case GOTD_IMSG_RECV_PACKFILE:
+			err = protect_refs_from_deletion();
+			if (err)
+				break;
 			err = recv_packfile(&have_packfile, &imsg);
 			if (err) {
 				log_warnx("receive packfile: %s", err->msg);
@@ -1437,7 +1794,10 @@ repo_write_main(const char *title, const char *repo_pa
 
 void
 repo_write_main(const char *title, const char *repo_path,
-    int *pack_fds, int *temp_fds)
+    int *pack_fds, int *temp_fds,
+    struct got_pathlist_head *protected_tag_namespaces,
+    struct got_pathlist_head *protected_branch_namespaces,
+    struct got_pathlist_head *protected_branches)
 {
 	const struct got_error *err = NULL;
 	struct repo_write_client *client = &repo_write_client;
@@ -1454,6 +1814,9 @@ repo_write_main(const char *title, const char *repo_pa
 	repo_write.temp_fds = temp_fds;
 	repo_write.session_fd = -1;
 	repo_write.session_iev.ibuf.fd = -1;
+	repo_write.protected_tag_namespaces = protected_tag_namespaces;
+	repo_write.protected_branch_namespaces = protected_branch_namespaces;
+	repo_write.protected_branches = protected_branches;
 
 	STAILQ_INIT(&repo_write_client.ref_updates);
 
blob - cb5ff4a606c537ef026d2f095e10c280b2ebe87b
blob + e8192eec3947ce83dcedba9e20048cb0ff7dfc76
--- gotd/repo_write.h
+++ gotd/repo_write.h
@@ -14,5 +14,7 @@ void repo_write_main(const char *, const char *, int *
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-void repo_write_main(const char *, const char *, int *, int *);
+void repo_write_main(const char *, const char *, int *, int *,
+    struct got_pathlist_head *, struct got_pathlist_head *,
+    struct got_pathlist_head *);
 void repo_write_shutdown(void);
blob - beb02307a41d585965a6d2853eb38b69adeedf87
blob + 2baef036bd213663876faf58ee76e34028149d55
--- gotsh/gotsh.c
+++ gotsh/gotsh.c
@@ -31,6 +31,7 @@
 
 #include "got_error.h"
 #include "got_serve.h"
+#include "got_path.h"
 
 #include "gotd.h"
 
blob - 9722f2b79685f6dcd81c590ca357729edac35a25
blob + 46b6937a40c4a6e26bc8057ab744e6a55b88271c
--- lib/error.c
+++ lib/error.c
@@ -226,8 +226,8 @@ static const struct got_error got_errors[] = {
 	{ GOT_ERR_BAD_REQUEST, "unexpected request received" },
 	{ GOT_ERR_CLIENT_ID, "unknown client identifier" },
 	{ GOT_ERR_REPO_TEMPFILE, "no repository tempfile available" },
-	{ GOT_ERR_REFS_PROTECTED, "reference namespace may not be modified" },
-	{ GOT_ERR_REF_PROTECTED," reference may not be modified" },
+	{ GOT_ERR_REFS_PROTECTED, "reference namespace is protected" },
+	{ GOT_ERR_REF_PROTECTED, "reference is protected" },
 	{ GOT_ERR_REF_BUSY, "reference cannot be updated; please try again" },
 	{ GOT_ERR_COMMIT_BAD_AUTHOR, "commit author formatting would "
 	    "make Git unhappy" },
blob - 4b96e21f407d84ac399adbccb5d7e21cf86dad1f
blob + 767c404755f278059ea3f7b3890ee3a3d0188974
--- lib/got_lib_object_parse.h
+++ lib/got_lib_object_parse.h
@@ -23,6 +23,8 @@ const struct got_error *got_object_qid_alloc_partial(s
  */
 char *got_object_id_hex(struct got_object_id *, char *, size_t);
 
+const struct got_error *got_object_type_label(const char **, int);
+
 const struct got_error *got_object_qid_alloc_partial(struct got_object_qid **);
 struct got_commit_object *got_object_commit_alloc_partial(void);
 struct got_tree_entry *got_alloc_tree_entry_partial(void);
blob - 0d4231ebe983b9f0f36e32dc6ea4cfa217100d03
blob + babca5450a093226d85ce667e921aa5d399584b8
--- lib/object_open_io.c
+++ lib/object_open_io.c
@@ -86,7 +86,28 @@ got_object_open_from_packfile(struct got_object **obj,
     struct got_pack *pack, struct got_packidx *packidx, int obj_idx,
     struct got_repository *repo)
 {
-	return got_error(GOT_ERR_NOT_IMPL);
+	const struct got_error *err;
+
+	*obj = got_repo_get_cached_object(repo, id);
+	if (*obj != NULL) {
+		(*obj)->refcnt++;
+		return NULL;
+	}
+
+	err = got_packfile_open_object(obj, pack, packidx, obj_idx, id);
+	if (err)
+		return err;
+	(*obj)->refcnt++;
+
+	err = got_repo_cache_object(repo, id, *obj);
+	if (err) {
+		if (err->code == GOT_ERR_OBJ_EXISTS ||
+		    err->code == GOT_ERR_OBJ_TOO_LARGE)
+			err = NULL;
+		return err;
+	}
+	(*obj)->refcnt++;
+	return NULL;
 }
 
 const struct got_error *
blob - 29c7c842065b16ecb3aa7fe09de578199bb18ed0
blob + 831119712bcf2cd3955126858b0726abcb140445
--- lib/object_parse.c
+++ lib/object_parse.c
@@ -110,6 +110,33 @@ void
 	return got_sha1_digest_to_str(id->sha1, buf, len);
 }
 
+const struct got_error *
+got_object_type_label(const char **label, int obj_type)
+{
+	const struct got_error *err = NULL;
+
+	switch (obj_type) {
+	case GOT_OBJ_TYPE_BLOB:
+		*label = GOT_OBJ_LABEL_BLOB;
+		break;
+	case GOT_OBJ_TYPE_TREE:
+		*label = GOT_OBJ_LABEL_TREE;
+		break;
+	case GOT_OBJ_TYPE_COMMIT:
+		*label = GOT_OBJ_LABEL_COMMIT;
+		break;
+	case GOT_OBJ_TYPE_TAG:
+		*label = GOT_OBJ_LABEL_TAG;
+		break;
+	default:
+		*label = NULL;
+		err = got_error(GOT_ERR_OBJ_TYPE);
+		break;
+	}
+
+	return err;
+}
+
 void
 got_object_close(struct got_object *obj)
 {
blob - 25889d5c1fc15e6c52fd8f251cc2c9c692d0cbcf
blob + 66f9a20dbc56e8dba38401264f0d16410a95e9cf
--- lib/pack_index.c
+++ lib/pack_index.c
@@ -103,33 +103,6 @@ get_obj_type_label(const char **label, int obj_type)
 }
 
 static const struct got_error *
-get_obj_type_label(const char **label, int obj_type)
-{
-	const struct got_error *err = NULL;
-
-	switch (obj_type) {
-	case GOT_OBJ_TYPE_BLOB:
-		*label = GOT_OBJ_LABEL_BLOB;
-		break;
-	case GOT_OBJ_TYPE_TREE:
-		*label = GOT_OBJ_LABEL_TREE;
-		break;
-	case GOT_OBJ_TYPE_COMMIT:
-		*label = GOT_OBJ_LABEL_COMMIT;
-		break;
-	case GOT_OBJ_TYPE_TAG:
-		*label = GOT_OBJ_LABEL_TAG;
-		break;
-	default:
-		*label = NULL;
-		err = got_error(GOT_ERR_OBJ_TYPE);
-		break;
-	}
-
-	return err;
-}
-
-static const struct got_error *
 read_checksum(uint32_t *crc, struct got_hash *ctx, int fd, size_t len)
 {
 	uint8_t buf[8192];
@@ -239,7 +212,7 @@ read_packed_object(struct got_pack *pack, struct got_i
 		if (err)
 			break;
 		got_hash_init(&ctx, GOT_HASH_SHA1);
-		err = get_obj_type_label(&obj_label, obj->type);
+		err = got_object_type_label(&obj_label, obj->type);
 		if (err) {
 			free(data);
 			break;
@@ -438,7 +411,7 @@ resolve_deltified_object(struct got_pack *pack, struct
 	err = got_delta_chain_get_base_type(&base_obj_type, &deltas);
 	if (err)
 		goto done;
-	err = get_obj_type_label(&obj_label, base_obj_type);
+	err = got_object_type_label(&obj_label, base_obj_type);
 	if (err)
 		goto done;
 	if (asprintf(&header, "%s %zd", obj_label, len) == -1) {
blob - 43f08a42a8c29c7ff51d53a6167ba9fdd8ff9e37
blob + 4fef3e998b0e8055a0acac216cc71828bdcea5c5
--- regress/gotd/Makefile
+++ regress/gotd/Makefile
@@ -3,7 +3,8 @@ REGRESS_TARGETS=test_repo_read test_repo_read_group \
 REGRESS_TARGETS=test_repo_read test_repo_read_group \
 	test_repo_read_denied_user test_repo_read_denied_group \
 	test_repo_read_bad_user test_repo_read_bad_group \
-	test_repo_write test_repo_write_empty test_request_bad
+	test_repo_write test_repo_write_empty test_request_bad \
+	test_repo_write_protected
 NOOBJ=Yes
 CLEANFILES=gotd.conf
 
@@ -134,6 +135,19 @@ prepare_test_repo: ensure_root
 	@$(GOTD_TRAP); $(GOTD_START_CMD)
 	@$(GOTD_TRAP); sleep .5
 
+start_gotd_rw_protected: ensure_root
+	@echo 'listen on "$(GOTD_SOCK)"' > $(PWD)/gotd.conf
+	@echo "user $(GOTD_USER)" >> $(PWD)/gotd.conf
+	@echo 'repository "test-repo" {' >> $(PWD)/gotd.conf
+	@echo '    path "$(GOTD_TEST_REPO)"' >> $(PWD)/gotd.conf
+	@echo '    permit rw $(GOTD_DEVUSER)' >> $(PWD)/gotd.conf
+	@echo '    protect branch "foo"' >> $(PWD)/gotd.conf
+	@echo '    protect tag namespace "refs/tags/"' >> $(PWD)/gotd.conf
+	@echo '    protect branch "refs/heads/main"' >> $(PWD)/gotd.conf
+	@echo "}" >> $(PWD)/gotd.conf
+	@$(GOTD_TRAP); $(GOTD_START_CMD)
+	@$(GOTD_TRAP); sleep .5
+
 prepare_test_repo: ensure_root
 	@chown ${GOTD_USER} "${GOTD_TEST_REPO}"
 	@su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./prepare_test_repo.sh'
@@ -189,6 +203,12 @@ test_repo_write_empty: prepare_test_repo_empty start_g
 		'env $(GOTD_TEST_ENV) sh ./repo_write_empty.sh'
 	@$(GOTD_STOP_CMD) 2>/dev/null
 	@su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./check_test_repo.sh'
+
+test_repo_write_protected: prepare_test_repo start_gotd_rw_protected
+	@-$(GOTD_TRAP); su ${GOTD_TEST_USER} -c \
+		'env $(GOTD_TEST_ENV) sh ./repo_write_protected.sh'
+	@$(GOTD_STOP_CMD) 2>/dev/null
+	@su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./check_test_repo.sh'
 	
 test_request_bad: prepare_test_repo_empty start_gotd_ro
 	@-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \
blob - /dev/null
blob + 4a5abbaf3b9c5b810ba8185addece65961258f6f (mode 644)
--- /dev/null
+++ regress/gotd/repo_write_protected.sh
@@ -0,0 +1,306 @@
+#!/bin/sh
+#
+# Copyright (c) 2023 Stefan Sperling <stsp@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+. ./common.sh
+
+test_create_protected_branch() {
+	local testroot=`test_init create_protected_branch 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	(cd $testroot/wt && got branch foo) >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got branch failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo modified alpha > $testroot/wt/alpha
+	(cd $testroot/wt && got commit -m 'edit alpha') >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got commit failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+	local commit_id=`git_show_branch_head $testroot/repo-clone foo`
+
+	# Creating a new branch should succeed.
+	got send -q -r $testroot/repo-clone -b foo 2> $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Verify that the send operation worked fine.
+	got clone -l ${GOTD_TEST_REPO_URL} | grep foo > $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone -l failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	echo "refs/heads/foo: $commit_id" > $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+	fi
+
+	test_done "$testroot" $ret
+}
+
+test_modify_protected_tag_namespace() {
+	local testroot=`test_init modify_protected_tag_namespace`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got tag -r $testroot/repo-clone -m "1.0" 1.0 >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got tag failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Creating a new tag should succeed.
+	got send -q -r $testroot/repo-clone -t 1.0 2> $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got ref -r $testroot/repo-clone -d refs/tags/1.0 > /dev/null
+	got tag -r $testroot/repo-clone -m "another 1.0" 1.0 >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got tag failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Overwriting an existing tag should fail.
+	got send -q -f -r $testroot/repo-clone -t 1.0 2> $testroot/stderr
+	ret=$?
+	if [ $ret == 0 ]; then
+		echo "got send succeeded unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	if ! egrep -q '(gotsh|got-send-pack): refs/tags/: reference namespace is protected' \
+		$testroot/stderr; then
+		echo -n "error message unexpected or missing: " >&2
+		cat $testroot/stderr >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Deleting an existing tag should fail.
+	# 'got send' cannot even do this so we use 'git push'.
+	(cd $testroot/repo-clone && git push -q -d origin refs/tags/1.0 \
+		2> $testroot/stderr)
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "git push -d succeeded unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	if ! egrep -q '(fatal: remote error|gotsh): refs/tags/: reference namespace is protected' \
+		$testroot/stderr; then
+		echo -n "error message unexpected or missing: " >&2
+		cat $testroot/stderr >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	test_done "$testroot" 0
+}
+
+test_delete_protected_branch() {
+	local testroot=`test_init delete_protected_branch`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	if got send -q -r $testroot/repo-clone -d main 2> $testroot/stderr; then
+		echo "got send succeeded unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	if ! egrep -q '(gotsh|got-send-pack): refs/heads/main: reference is protected' \
+		$testroot/stderr; then
+		echo -n "error message unexpected or missing: " >&2
+		cat $testroot/stderr >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	test_done "$testroot" 0
+}
+
+test_modify_protected_branch() {
+	local testroot=`test_init modify_protected_branch`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout $testroot/repo-clone $testroot/wt >/dev/null
+
+	for i in 1 2 3; do
+		echo "more alpha" >> $testroot/wt/alpha
+		(cd $testroot/wt && got commit -m "more" >/dev/null)
+	done
+	local commit_id=`git_show_head $testroot/repo-clone`
+	local parent_commit_id=`git_show_parent_commit $testroot/repo-clone \
+		"$commit_id"`
+
+	# Modifying the branch by adding new commits on top should succeed.
+	got send -q -r $testroot/repo-clone 2> $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Verify that the send operation worked fine.
+	got clone -l ${GOTD_TEST_REPO_URL} | grep main > $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone -l failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	echo "HEAD: refs/heads/main" > $testroot/stdout.expected
+	echo "refs/heads/main: $commit_id" >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" $ret
+		return 1
+	fi
+
+	# Attempt to remove the tip commit
+	(cd $testroot/wt && got update -c "$parent_commit_id" >/dev/null)
+	(cd $testroot/wt && got histedit -d >/dev/null)
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got histedit failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# The client should reject sending without -f.
+	got send -q -r $testroot/repo-clone 2> $testroot/stderr
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "got send succeeded unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo 'got: refs/heads/main: fetch and rebase required' \
+		>> $testroot/stderr.expected
+	if ! cmp -s $testroot/stderr.expected $testroot/stderr; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Try again with -f.
+	got send -q -r $testroot/repo-clone -f 2> $testroot/stderr
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "got send succeeded unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	if ! egrep -q '(gotsh|got-send-pack): refs/heads/main: reference is protected' \
+		$testroot/stderr; then
+		echo -n "error message unexpected or missing: " >&2
+		cat $testroot/stderr >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Verify that the send -f operation did not have any effect.
+	got clone -l ${GOTD_TEST_REPO_URL} | grep main > $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone -l failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	echo "HEAD: refs/heads/main" > $testroot/stdout.expected
+	echo "refs/heads/main: $commit_id" >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+	fi
+
+	test_done "$testroot" $ret
+}
+
+test_parseargs "$@"
+run_test test_create_protected_branch
+run_test test_modify_protected_tag_namespace
+run_test test_delete_protected_branch
+run_test test_modify_protected_branch