crates/gunnar-core/src/model.rs
//! The ACL model: principals, keys, namespaces, repositories, grants.
//!
//! Everything here is plain data with `serde` on it, because it is stored as a
//! JSON document in the object store (see [`crate::doc`]) and handed to gRPC
//! and to the SSH layer unchanged. No behaviour lives on these types beyond
//! validation and the level ordering that the ACL is built on.
use std::fmt;
use crate::error::{Error, Result};
use crate::sandbox::{check_component, RepoRef};
/// Milliseconds since the unix epoch. Signed, to match what gRPC carries.
pub type UnixMillis = i64;
/// The default branch a repository gets when none is named.
///
/// Re-exported, not declared: `gunnar_store` writes a fresh repository's `HEAD`
/// out of the same constant, and two crates each holding their own copy of this
/// name is what `point_head_at` exists to stop repeating.
pub use gunnar_store::DEFAULT_BRANCH;
/// Wall-clock now, in milliseconds since the unix epoch.
///
/// Clamped at zero rather than panicking on a clock before 1970. An appliance
/// with no RTC boots at the epoch, and refusing to serve because the clock is
/// wrong would be the wrong failure.
pub fn now_ms() -> UnixMillis {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as UnixMillis)
.unwrap_or(0)
}
/// What kind of thing an identity is.
///
/// The distinction is not decorative: a `Deploy` key is expected to be
/// read-only and short-lived, and a UI that cannot tell a human from a CI
/// runner cannot show a useful key list.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum PrincipalKind {
/// A person.
User,
/// A machine account: CI, a bot, another service.
Machine,
/// A deploy key: one repository, usually read-only.
Deploy,
/// **A group of principals** (punkt 9).
///
/// A group is a principal like any other — it has an id, it can be the
/// grantee of a [`Grant`] at any scope and level, and `grant`/`revoke`/`may`
/// treat it no differently from a user (one implementation, no new grant type). What is
/// special is only that it *holds no keys* and is *never authenticated as*:
/// a group is a name that grants attach to, and the members of the group
/// inherit those grants. Membership is an edge, tracked beside the identity
/// table (`AuthTable::add_to_group`), not a field on the principal, so the
/// same principal record serves a group and a user.
Group,
/// **Nobody.** The caller presented no key at all.
///
/// There is exactly one principal of this kind, [`Principal::anonymous`].
/// It is never stored in the identity table, it can never hold a key and it
/// can never hold a grant. It exists so that "a stranger clones a public
/// repository" is answered by [`AuthTable::may`](crate::AuthTable::may)
/// like every other access question, rather than by a second code path
/// that could disagree with the first (one implementation).
Anonymous,
}
/// Someone, or something, that can authenticate.
///
/// **There are no OS users.** The server has no `/etc/passwd`, no home
/// directories and no `authorized_keys`; this table is the entire identity
/// system (the ACL model).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Principal {
/// Stable identifier, used as the key everywhere else. Same character
/// rules as a namespace, so it can never be mistaken for a path.
///
/// **It is also the account**: the namespace this principal intrinsically
/// owns ([`AuthTable::owns_account`](crate::AuthTable::owns_account)), the
/// key a gate is filed under, and what a policy file's
/// `allowed_principals` names. Chosen once, at creation, and never moved.
/// Everything that may change about a principal changes in `name`.
pub id: String,
/// Display name, for humans and for UIs.
///
/// **Not the identity.** It defaults to the id and it may be changed
/// ([`AuthTable::rename_principal`](crate::AuthTable::rename_principal))
/// without touching a key, a grant, a membership, a gate or the namespace.
/// It is unique on the table by construction — see the `names` index in
/// [`AuthTable`](crate::AuthTable) — and may not equal another principal's
/// id either, so a listing never shows two rows a human reads as one.
pub name: String,
/// What kind of identity this is.
pub kind: PrincipalKind,
/// When the row was created, by the table's clock, in unix milliseconds.
///
/// Stamped by [`AuthTable::add_principal`](crate::AuthTable::add_principal)
/// — one writer — and `0` for a row from before the field existed, which is
/// what the wire reported for every principal until then. A caller that
/// pins a principal by `(id, created)` can tell a delete-and-recreate under
/// the same id from the row it first saw; the id alone cannot say, because
/// an account slug is claimable again once its account is purged.
#[serde(default)]
pub created: UnixMillis,
}
/// The id of the one [`PrincipalKind::Anonymous`] principal.
///
/// **Reserved.** [`Principal::new`] and
/// [`AuthTable::add_principal`](crate::AuthTable::add_principal) both refuse
/// it, so no registered principal can ever be spelled this way and no public
/// key can ever be registered to it. That is what makes the rule in
/// [`AuthTable::may`](crate::AuthTable::may) a statement about a name that
/// cannot be claimed: *this id means "nobody", and nobody may read a public
/// repository and nothing else*.
pub const ANONYMOUS_PRINCIPAL_ID: &str = "anonymous";
impl Principal {
/// Validate an id and build a principal.
///
/// Refuses [`ANONYMOUS_PRINCIPAL_ID`]: the anonymous identity is not
/// something an operator registers, and a registered principal wearing that
/// name would be handed the public-read rule of
/// [`AuthTable::may`](crate::AuthTable::may) for free.
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
kind: PrincipalKind,
) -> Result<Self> {
let id = id.into();
check_component(&id, &id)?;
if id == ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(id));
}
Ok(Principal {
id,
name: name.into(),
kind,
// The table stamps it on insert; a value chosen here would be the
// caller's clock, and two clocks for one field is the twin.
created: 0,
})
}
/// **Nobody.** The principal an unauthenticated caller is served as.
///
/// Built directly rather than through [`Principal::new`], which refuses the
/// reserved id. There is no other constructor for it and no way to store
/// one, so every value of this shape in the process came from here.
pub fn anonymous() -> Self {
Principal {
id: ANONYMOUS_PRINCIPAL_ID.to_owned(),
name: "anonymous".to_owned(),
kind: PrincipalKind::Anonymous,
created: 0,
}
}
/// Is this the anonymous principal?
///
/// **By id, not by `kind`.** The id is what
/// [`AuthTable::may`](crate::AuthTable::may) is keyed on and what a grant
/// would name; a `kind` that disagreed with the id would be the twinning
/// this whole design avoids.
pub fn is_anonymous(&self) -> bool {
self.id == ANONYMOUS_PRINCIPAL_ID
}
}
/// A registered public key.
///
/// The fingerprint is the primary key, because that is what an SSH handshake
/// gives the server: russh hands over the offered key, gunnar fingerprints it
/// and looks it up. The SSH *username* is decorative (the ACL model).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct PublicKey {
/// OpenSSH's `SHA256:<base64>` fingerprint.
pub fingerprint_sha256: String,
/// The algorithm name as OpenSSH writes it: `ssh-ed25519`, `ssh-rsa`, …
pub algo: String,
/// Which principal this key authenticates.
pub principal_id: String,
/// When it was registered.
pub added: UnixMillis,
/// When it last authenticated, if ever.
pub last_used: Option<UnixMillis>,
/// When it stops being accepted, if ever.
pub expires: Option<UnixMillis>,
/// The `authorized_keys`-style line it was registered from.
///
/// Kept because `gunnar-policy` needs the key material itself to verify
/// `gpg.format=ssh` commit signatures (the pre-receive gate), and a fingerprint cannot
/// verify a signature.
pub openssh: String,
}
impl PublicKey {
/// Is this key still valid at `now`?
pub fn is_valid_at(&self, now: UnixMillis) -> bool {
match self.expires {
None => true,
Some(expires) => now < expires,
}
}
}
/// **A web credential — a person's WebAuthn passkey** (Stage L, 2026-10-04).
///
/// A row in the identity table beside the person's SSH keys, so it lives and
/// dies with the principal, rides the same compare-and-swap, and reaches the
/// twin with the rest of `refs/gunnar/auth`. gunnar verifies a login's
/// assertion against `public_key_hex` itself: the console relays the browser's
/// answer and holds no credential of its own.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebCredential {
/// The authenticator's raw credential id, base64url without padding — the
/// map key, and what a login names.
pub id: String,
/// The principal it logs in as. Only a USER may hold one.
pub principal_id: String,
/// What the person called it.
pub label: String,
/// The P-256 public key, SEC1 uncompressed (`04 || X || Y`), lowercase hex.
pub public_key_hex: String,
/// The authenticator's signature counter as of the last accepted login.
pub sign_count: u32,
/// When it was registered.
pub created: UnixMillis,
/// When it last logged in, if ever.
#[serde(default)]
pub last_used: Option<UnixMillis>,
/// **The session keys its logins enrolled** (fingerprints), so revoking
/// the passkey ends the sessions it opened. Pruned at each login to the
/// keys still held and unexpired.
#[serde(default)]
pub sessions: Vec<String>,
}
/// **The operator's first invite, as the identity table keeps it** (Stage L,
/// 2026-10-04; IDENTITY-HOST 2026-10-27): the installer's break-glass token by
/// its hash only, for the operator's first passkey. Invites are the identity
/// core's (`nornir-identity`); this row is the one the core is FED (it reads it
/// as `sha256:<hex>` onto the operator and spends it there). The shape is the
/// table's from before the move, so a table written then still reads.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FirstInviteRow {
/// The invite's public id — what a listing and a revocation name.
pub id: String,
/// The person whose passkey it enrols.
pub principal_id: String,
/// Lowercase hex SHA-256 of the secret. Never the secret.
pub secret_hash: String,
/// Who created it.
pub created_by: String,
/// When.
pub created: UnixMillis,
/// When it stops being redeemable.
pub expires: UnixMillis,
/// Free text from the inviter.
#[serde(default)]
pub note: String,
/// The server-wide grant a redemption applies (the console's operator or
/// admin), if any. Only a server admin's invite carries one.
#[serde(default)]
pub server_level: Option<Level>,
}
/// A registered **access token**: the credential smart HTTP authenticates with
///
///
/// # Why this is not a second identity model
///
/// It is a second *authentication* method for the identity model that already
/// exists. A token names a [`Principal`] and nothing else. It carries no grant,
/// no level and no scope, so every authorisation question it can produce is
/// still answered by [`AuthTable::may`](crate::AuthTable::may) against that
/// principal's grants — the same call, on the same table, that an SSH key
/// produces. Revoking the token removes a way in; it changes nobody's access.
///
/// # The secret is not here, and cannot be
///
/// [`Self::hash`] is the SHA-256 of the token secret, hex, and it is also the
/// key this record is stored under. That is deliberate on both counts:
///
/// * the stored table never holds a usable credential, so a leaked auth
/// document (it is a git blob, and every version of it is kept — see
/// [`doc`](crate::doc)) does not hand anybody a login;
/// * a lookup is *hash the presented secret, probe once*, which is O(1) and
/// does not walk the token list. A table keyed by anything else would either
/// scan on every HTTP request or need a second index to avoid it.
///
/// The hash is therefore also the token's **public id**: it is what
/// `gunnar admin token list` prints and what revocation names. Printing a hash
/// is safe; printing the secret again is not possible, which is the point.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct AccessToken {
/// SHA-256 of the secret, lowercase hex. The primary key, and the id an
/// operator sees.
pub hash: String,
/// Which principal this token authenticates.
pub principal_id: String,
/// An operator-supplied label: *which laptop, which CI job*. Never part of
/// the credential.
pub label: String,
/// When it was created.
pub created: UnixMillis,
/// When it last authenticated, if ever.
pub last_used: Option<UnixMillis>,
/// When it stops being accepted, if ever.
pub expires: Option<UnixMillis>,
}
impl AccessToken {
/// Is this token still valid at `now`?
///
/// Same shape as [`PublicKey::is_valid_at`] and deliberately so: expiry is
/// one rule, and two credentials that expired by different arithmetic would
/// be two rules (one implementation).
pub fn is_valid_at(&self, now: UnixMillis) -> bool {
match self.expires {
None => true,
Some(expires) => now < expires,
}
}
}
/// A tenant. First-class from day one (multi-tenancy), because adding it later would
/// mean migrating every stored path and every grant.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Namespace {
/// The name, possibly nested (`team/sub`).
pub name: String,
/// **The exploded read cache this account's repositories may hold**, in
/// bytes — the quota, and the one number a plan is allowed to move.
///
/// `None` is *"take the server's default"*, which is
/// `min(10 % of /data, 8 GiB)` per repository. `Some(0)` is unlimited and
/// is a deliberate answer, not an absent one — which is why this is an
/// `Option` and not a `u64` with a magic value.
///
/// What it buys, and what it does not: a bigger cache means more objects
/// are already inflated, so reads are FAST; a smaller one means more reads
/// re-inflate their own delta chain out of the pack (~0.5–2 ms an object),
/// which is SLOW. It is never a refusal and never an error — an account at
/// its budget serves every object it has, correctly, more slowly. Nothing
/// in this field can lose data: every byte it bounds is re-derivable from
/// the packs, which are not bounded by it.
///
/// `serde(default)` so a control document written before the quota field loads
/// unchanged and every existing namespace reads as "the default".
#[serde(default)]
pub explode_budget_bytes: Option<u64>,
/// **The namespace is public** — an explicit setting of the account admin's
/// (owner, 2026-10-03: "default for all accounts is that emitted events and
/// namespaces are not public, such things has to be set").
///
/// Private (`false`, the default) is what every namespace is until its
/// account admin says otherwise: somebody outside the account learns of it
/// only through a public repository in it, and a private namespace holding
/// no public repository answers exactly as one that does not exist. Public:
/// the namespace itself is shown to everyone, even while it holds no public
/// repository. Either way a PRIVATE repository in it is never shown to an
/// outsider — that is the repository's own visibility.
///
/// Skipped when `false`, so a default namespace's stored row is byte for
/// byte what it was before the field existed, and every older row reads as
/// private.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub public: bool,
/// **The account's emitted events are public** — the account admin's
/// explicit setting, `false` by default: until it is set, the events of an
/// account are shown to nobody outside it. Skipped when `false`, as
/// [`Namespace::public`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub events_public: bool,
}
/// Which hash a repository's objects are named by.
///
/// gunnar's own enum rather than [`gix_hash::Kind`] so the wire and storage
/// representations do not depend on a gix version's `serde` feature. sha256 is
/// the default for new repositories; sha1 stays fully
/// supported.
#[derive(
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Debug,
Default,
serde::Serialize,
serde::Deserialize,
)]
pub enum HashKind {
/// Git's original hash.
Sha1,
/// The default for new repositories.
#[default]
Sha256,
}
impl From<HashKind> for gix_hash::Kind {
fn from(kind: HashKind) -> Self {
match kind {
HashKind::Sha1 => gix_hash::Kind::Sha1,
HashKind::Sha256 => gix_hash::Kind::Sha256,
}
}
}
/// `gix_hash::Kind` is `#[non_exhaustive]`, so the conversion back is fallible
/// by construction: a gix release that adds a third hash must not silently be
/// stored as one of gunnar's two.
impl TryFrom<gix_hash::Kind> for HashKind {
type Error = Error;
fn try_from(kind: gix_hash::Kind) -> Result<Self> {
match kind {
gix_hash::Kind::Sha1 => Ok(HashKind::Sha1),
gix_hash::Kind::Sha256 => Ok(HashKind::Sha256),
other => Err(Error::Other(anyhow::anyhow!(
"gunnar does not know the hash kind {other}"
))),
}
}
}
impl std::fmt::Display for HashKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
HashKind::Sha1 => "sha1",
HashKind::Sha256 => "sha256",
})
}
}
/// Who may read a repository without an explicit grant.
///
/// > **⚠ The meaning of [`Visibility::Public`] changed on 2026-08-03.**
/// > It used to mean "any *authenticated* principal may read", because gunnar
/// > had no unauthenticated path at all and the field therefore honoured
/// > nothing a private repository did not already get from a grant. It now
/// > means what the word says: **readable by anyone, with no key**. An operator
/// > who marked a repository public under the old reading has published it
/// > under the new one. [`Visibility::Private`] is still the default and is
/// > unchanged, and an appliance that must never serve a stranger turns the
/// > anonymous path off at the listener
/// > (`SshConfig::without_anonymous_access`).
#[derive(
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Debug,
Default,
serde::Serialize,
serde::Deserialize,
)]
pub enum Visibility {
/// Only principals with a grant. The default, and the only safe one.
///
/// A private repository is **indistinguishable from one that does not
/// exist** to a caller who may not read it. The SSH dispatch answers both
/// with the same string, so a stranger cannot enumerate private
/// repositories one guess at a time.
#[default]
Private,
/// Readable by **anyone**, including a caller who presented no key at all
/// That is what [`PrincipalKind::Anonymous`] is for.
///
/// Read only, and only `git-upload-pack`. Writing still needs a grant, so
/// [`Level::Write`] and [`Level::Admin`] are unaffected by this field.
Public,
}
/// A repository, as the catalog holds it.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Repo {
/// The namespace it lives in, possibly nested.
pub namespace: String,
/// Its name, without any `.git` suffix.
pub name: String,
/// sha1 or sha256. Fixed at creation; a repository never changes hash.
pub hash_kind: HashKind,
/// Full ref name, e.g. `refs/heads/main`.
pub default_branch: String,
/// Who may read it without a grant.
pub visibility: Visibility,
/// When it was created.
pub created: UnixMillis,
/// Free-text description, for UIs.
pub description: String,
/// **Encrypted repository** (2026-10-05): the server refuses any push
/// that would put plaintext on it — every new blob must be a
/// gunnar-crypt envelope (the `encrypted` push gate). The server holds no
/// key either way. Only the ACCOUNT admin sets it. Skipped when `false`, so
/// every older row reads as not encrypted and is stored byte for byte as
/// before.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub encrypted: bool,
}
impl Repo {
/// Its path, as the sandbox produces one.
pub fn path(&self) -> RepoRef {
RepoRef::new(&self.namespace, &self.name)
.expect("a catalogued repo was built from a validated RepoRef")
}
/// Its catalog key, `<namespace>/<repo>`.
pub fn key(&self) -> String {
format!("{}/{}", self.namespace, self.name)
}
}
/// The same string [`Repo::key`] returns, written straight into the formatter.
///
/// `key()` allocates; a `repo = %repo` span or log field does not. Both spell
/// `<namespace>/<repo>` and there is one implementation of that shape here, so
/// a log line and a catalog lookup can never disagree about what a repository
/// is called (one implementation). `RepoRef` already had exactly this pair.
impl fmt::Display for Repo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.namespace, self.name)
}
}
/// How much access a grant confers. Ordered: `Read < Write < Admin`, and the
/// ACL compares with `>=`, so an admin can push and a writer can fetch.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum Level {
/// `git-upload-pack`: clone and fetch.
Read,
/// `git-receive-pack`: push. Implies [`Level::Read`].
Write,
/// Administer the repository, namespace or server. Implies
/// [`Level::Write`].
Admin,
}
impl Level {
/// Does holding `self` satisfy a requirement of `needed`?
///
/// The ordering is the whole point, and it is why the SSH dispatch compares
/// levels instead of matching on which verb it got: adding a third verb
/// (LFS over SSH) then cannot forget a case. `gunnar-ssh` had its own copy of
/// this enum and this method; it now uses this one.
pub fn permits(self, needed: Level) -> bool {
self >= needed
}
}
impl std::fmt::Display for Level {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Level::Read => "read",
Level::Write => "write",
Level::Admin => "admin",
})
}
}
/// What a grant applies to.
///
/// # Deviation from the brief, recorded
///
/// The brief writes `scope: Namespace|Repo`. [`Scope::Server`] is added,
/// because without it the bootstrap admin of the secret-store seam has nothing it is allowed to
/// do: it cannot create the first namespace, so a freshly bootstrapped server
/// would be reachable and useless. A strict superset: no namespace-scoped or
/// repo-scoped behaviour changes.
#[derive(
Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum Scope {
/// The whole server. Only the bootstrap admin and whoever it appoints.
Server,
/// One namespace, matched **exactly**: a grant on `team` does *not* reach
/// `team/sub`. Nested namespaces are separate tenants, and inheritance
/// would be a privilege escalation that nobody wrote down.
Namespace(String),
/// One repository.
Repo(RepoRef),
}
impl Scope {
/// Does this scope cover `repo`?
pub fn covers(&self, repo: &Repo) -> bool {
match self {
Scope::Server => true,
Scope::Namespace(name) => name == &repo.namespace,
Scope::Repo(path) => path.namespace() == repo.namespace && path.name() == repo.name,
}
}
/// Does this scope cover the namespace `name`?
pub fn covers_namespace(&self, name: &str) -> bool {
match self {
Scope::Server => true,
Scope::Namespace(scoped) => scoped == name,
Scope::Repo(_) => false,
}
}
}
impl std::fmt::Display for Scope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Scope::Server => f.write_str("server"),
Scope::Namespace(name) => write!(f, "namespace {name}"),
Scope::Repo(path) => write!(f, "repo {path}"),
}
}
}
/// One row of the ACL: this principal has this much access to this scope.
#[derive(
Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub struct Grant {
/// Which principal, by [`Principal::id`].
pub principal: String,
/// What it applies to.
pub scope: Scope,
/// How much.
pub level: Level,
}
impl Grant {
/// A grant on one repository.
pub fn repo(principal: impl Into<String>, path: RepoRef, level: Level) -> Self {
Grant {
principal: principal.into(),
scope: Scope::Repo(path),
level,
}
}
/// A grant on a whole namespace.
pub fn namespace(
principal: impl Into<String>,
namespace: impl Into<String>,
level: Level,
) -> Self {
Grant {
principal: principal.into(),
scope: Scope::Namespace(namespace.into()),
level,
}
}
/// A grant on the whole server.
pub fn server(principal: impl Into<String>, level: Level) -> Self {
Grant {
principal: principal.into(),
scope: Scope::Server,
level,
}
}
}
/// Check that `name` is a legal full ref name under `refs/heads/`.
///
/// Reuses `gunnar_store`'s validator rather than growing a second opinion about
/// what a ref name is (one implementation). That one already rejects `..`, control
/// characters and empty components.
///
/// It used to be `FullName::new`, which validated *and* produced a value type.
/// `FullName` went with `RefStore`; the rules did not — they are a property of
/// git, not of a backend, and one of them (`refs/heads/../../etc/passwd`) is a
/// path-traversal boundary. They live in `gunnar_store::refname` now, as a
/// free function over `&str`, which is the shape `GitOps` speaks.
pub(crate) fn check_branch(name: &str) -> Result<String> {
if !name.starts_with("refs/heads/") {
return Err(Error::BadBranchName(format!(
"{name:?}: a default branch must be a full name under refs/heads/"
)));
}
gunnar_store::refname::validate(name)
.map_err(|err| Error::BadBranchName(format!("{name:?}: {err}")))?;
Ok(name.to_owned())
}