Commit Diff


commit - 1e73031b5b7f6d97d2de389636e620fe0de6201f
commit + 6d7eb4f7d125c942358a1f8edf1d350e74141112
blob - 0fba1db7959d1da077ff54a97824b6ec0255df29
blob + 4f337798294f24f408b1ba9738cf112d1f5d5a13
--- gotctl/gotctl.c
+++ gotctl/gotctl.c
@@ -31,6 +31,7 @@
 
 #include "got_error.h"
 #include "got_version.h"
+#include "got_path.h"
 
 #include "got_lib_gitproto.h"
 
blob - 04d2d6c654a526a45e5fc7661da7c4d82aff89f1
blob + 879ce9726af53b2e3df4d2233b5fb6db82d2ce52
--- gotd/gotd.c
+++ gotd/gotd.c
@@ -1697,6 +1697,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. */
 
@@ -1894,7 +1895,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 @@ Group names may be matched by prepending a colon
 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_imsg_auth {
 
 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 - ec712af51ff37e9a9c9342221a919cc6d0565305
blob + 56711725ea5a13b6c1664f42527978986ba7680d
--- gotd/imsg.c
+++ gotd/imsg.c
@@ -28,6 +28,7 @@
 #include <unistd.h>
 
 #include "got_error.h"
+#include "got_path.h"
 
 #include "got_lib_poll.h"
 
blob - 2c0352c2f41add38d42370550c49e764389806f5
blob + 4ac000801b8cadce8c293a8ff3878d4ed250d823
--- gotd/listen.c
+++ gotd/listen.c
@@ -32,6 +32,7 @@
 #include <unistd.h>
 
 #include "got_error.h"
+#include "got_path.h"
 
 #include "gotd.h"
 #include "log.h"
blob - 715588536b23a7e19527bba5a45c58c043647d7e
blob + d43ed3e568eed641142fe3b93aec55273e0b2876
--- gotd/parse.y
+++ gotd/parse.y
@@ -42,6 +42,7 @@
 
 #include "got_error.h"
 #include "got_path.h"
+#include "got_reference.h"
 
 #include "log.h"
 #include "gotd.h"
@@ -90,6 +91,14 @@ static int			 conf_limit_user_connections(const char *
 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 {
@@ -105,6 +114,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
@@ -227,6 +237,44 @@ conflags	: REQUEST TIMEOUT timeout		{
 		}
 		;
 
+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;
 
@@ -239,7 +287,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);
@@ -249,7 +298,8 @@ repository	: REPOSITORY 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);
@@ -283,6 +333,7 @@ repoopts1	: PATH STRING {
 				    GOTD_ACCESS_DENIED, 0, $2);
 			}
 		}
+		| protect
 		;
 
 repoopts2	: repoopts2 repoopts1 nl
@@ -330,17 +381,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 },
 	};
@@ -809,6 +864,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))
@@ -837,6 +895,94 @@ conf_new_access_rule(struct gotd_repo *repo, enum gotd
 	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)
 {
@@ -909,3 +1055,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 - 0b3f8f4df38cd7b80fc0ef9f616105f88ad2565a
blob + b44d02618319a11e9c8d2bfe2b43a20797a9522d
--- gotd/repo_imsg.c
+++ gotd/repo_imsg.c
@@ -28,6 +28,7 @@
 
 #include "got_error.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "got_lib_hash.h"
 
blob - 139661c41ad7cf22c2e398eb71d1739db24202c2
blob + 8f38840876aa9b8a6ecc9eb282b3ac515153d97f
--- gotd/repo_read.c
+++ gotd/repo_read.c
@@ -35,6 +35,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 - 7d216c30fd1e06492e24bd23fce03098762ada66
blob + c42b6ea7059df811d07c1a23bc6a6089f931691f
--- gotd/repo_write.c
+++ gotd/repo_write.c
@@ -44,6 +44,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"
@@ -66,6 +68,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 {
@@ -309,7 +314,7 @@ done:
 }
 
 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);
 
@@ -319,13 +324,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];
@@ -367,6 +638,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;
@@ -386,10 +663,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;
 
@@ -1046,7 +1323,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,
@@ -1079,17 +1358,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:
@@ -1097,10 +1410,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;
@@ -1307,6 +1661,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);
@@ -1443,7 +1800,10 @@ repo_write_dispatch(int fd, short event, void *arg)
 
 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;
@@ -1460,6 +1820,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 @@
  * 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 - 23e06a43a397b5bc76b1d9cf4901e41465d7af8a
blob + afd9bcda117c08da3020fefb04f6b713a6872af9
--- gotsh/gotsh.c
+++ gotsh/gotsh.c
@@ -29,6 +29,7 @@
 
 #include "got_error.h"
 #include "got_serve.h"
+#include "got_path.h"
 
 #include "gotd.h"
 
blob - 4129783e86df8d634cf23282925d3ef4658cafaa
blob + f02cebec77dbb2e815b7375242abbcc7c7d970bf
--- lib/error.c
+++ lib/error.c
@@ -234,8 +234,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 @@ struct got_pathlist_head;
  */
 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 - 7dd29c4dcce0d135de22b133789e30d3d6f523c7
blob + 413b0529c0a62fa7d9e0e6fdde7a89ac719515dd
--- lib/object_open_io.c
+++ lib/object_open_io.c
@@ -83,7 +83,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 - a902ff1efd4ecf292aa42457d41225adb7c7349f
blob + c0b1855c518c2630ff4a38ada9e593cbd10c74bd
--- lib/object_parse.c
+++ lib/object_parse.c
@@ -108,6 +108,33 @@ got_object_id_hex(struct got_object_id *id, char *buf,
 	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 - 69e5499d5cb289f6d9215e592c5eaa430e891b47
blob + a2fdf06a31ef966e67ff58156ee7fa833ff8e910
--- lib/pack_index.c
+++ lib/pack_index.c
@@ -102,33 +102,6 @@ putbe32(char *b, uint32_t n)
 }
 
 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];
@@ -238,7 +211,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;
@@ -437,7 +410,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 \
 	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 @@ start_gotd_rw: 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