feat: decompose members into per-username refs and add accounts
commit 2a92da4
feat: decompose members into per-username refs and add accounts
The single aggregated refs/meta/members blob becomes one
refs/meta/member/<username> ref per person, each a Member document with a
validity window and a Trust of leaf keys. The verifier globs
refs/meta/member/* and unions their in-window keys, so a member can be
added, refreshed, or revoked as an independent, separately-history’d ref. A
new refs/meta/account ref carries the repository’s account identity, and a
repo is an account by the presence of that ref — no central user table.
The member key validity window moves from per-key (Phase 1, on the
aggregated ref) to per-member, matching the decomposed model; the
allowed_signers principal stays a wildcard, since trust is still membership
of the key set rather than a binding to a signing identity.
This is a hard break with no fallback: refs/meta/members is no longer
read, consistent with the auth to members rename precedent.
feat: add the Member document and Trust::Keys enum on refs/meta/member/*
feat: union every refs/meta/member/* in the push verifier
feat: add git_ents::account — Account on refs/meta/account
feat: add git ents account create <username>
feat: target per-username refs from git ents members add/remove/list
Assisted-by: Claude:claude-opus-4-8
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents-server/src/verify.rs
@@ -1,17 +1,17 @@
//! The `pre-receive` verifier: a git hook that gates pushes on a signature from
//! a member.
//!
-//! When the trust list at `refs/meta/members` is empty the server is still in
+//! When no member is listed under `refs/meta/member/*` the server is still in
//! its open bootstrap window and every push is allowed, so the first member can
//! be pushed in. Once any member is listed, a push must carry a signed-push
//! certificate (`git push --signed`) whose anti-replay nonce git accepted and
-//! whose signature verifies against one of those keys.
+//! whose signature verifies against one of those members' in-window keys.
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
-use git_ents::signers::{self, Signer};
+use git_ents::signers::{self, Member};
/// Verify the push git is about to apply, returning `Ok(())` to accept it or
/// `Err(reason)` to reject it. The push certificate is read from the
@@ -19,7 +19,7 @@
pub fn pre_receive() -> Result<(), String> {
let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?;
let authorized =
- signers::load(&repo).map_err(|e| format!("could not read authorized signers: {e}"))?;
+ signers::load_all(&repo).map_err(|e| format!("could not read authorized signers: {e}"))?;
if authorized.is_empty() {
// No trust list pushed yet: stay open so the first signer can be added.
return Ok(());
@@ -41,7 +41,7 @@
/// Split the certificate into its signed payload and SSH signature, then accept
/// it only when `ssh-keygen -Y verify` trusts the signature against one of the
/// authorized keys.
-fn verify_certificate(authorized: &[Signer], certificate: &str) -> Result<(), String> {
+fn verify_certificate(authorized: &[Member], certificate: &str) -> Result<(), String> {
const MARKER: &str = "-----BEGIN SSH SIGNATURE-----";
let split = certificate
.find(MARKER)
crates/git-ents-server/tests/pre_receive.rs
@@ -87,10 +87,10 @@
valid_before: Option<&'a str>,
}
-/// Create a bare server repo wired to the `pre-receive` verifier whose
-/// `refs/meta/members` lists `members` in the real on-disk layout — a
-/// `members/<key-n>/` subtree per member holding a `key` blob and an
-/// `valid_after`/`valid_before` `Option` subtree each.
+/// Create a bare server repo wired to the `pre-receive` verifier with one
+/// `refs/meta/member/member-<n>` ref per member in the real on-disk layout — a
+/// `principal` blob, `valid_after`/`valid_before` `Option` subtrees, and a
+/// `trust/Keys/key` blob.
fn server_repo_with(base: &Path, members: &[Member]) -> PathBuf {
let repo = base.join("srv.git");
ok(
@@ -112,34 +112,41 @@
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
- if !members.is_empty() {
- let option_tree = |bound: Option<&str>| match bound {
- None => mktree(&repo, ""),
- Some(value) => {
- let blob = hash_object(&repo, value.as_bytes());
- mktree(&repo, &format!("100644 blob {blob}\tsome\n"))
- }
- };
- let mut tree_entries = String::new();
- for (index, member) in members.iter().enumerate() {
- let key = std::fs::read_to_string(member.pubkey).unwrap();
- let key_blob = hash_object(&repo, key.as_bytes());
- let after_tree = option_tree(member.valid_after);
- let before_tree = option_tree(member.valid_before);
- let member_tree = mktree(
- &repo,
- &format!(
- "100644 blob {key_blob}\tkey\n\
- 040000 tree {after_tree}\tvalid_after\n\
- 040000 tree {before_tree}\tvalid_before\n"
- ),
- );
- tree_entries.push_str(&format!("040000 tree {member_tree}\tkey-{index}\n"));
+ let option_tree = |bound: Option<&str>| match bound {
+ None => mktree(&repo, ""),
+ Some(value) => {
+ let blob = hash_object(&repo, value.as_bytes());
+ mktree(&repo, &format!("100644 blob {blob}\tsome\n"))
}
- let members_tree = mktree(&repo, &tree_entries);
- let root_tree = mktree(&repo, &format!("040000 tree {members_tree}\tmembers\n"));
- let commit = ok(&repo, "git", &["commit-tree", &root_tree, "-m", "members"]);
- ok(&repo, "git", &["update-ref", "refs/meta/members", &commit]);
+ };
+ for (index, member) in members.iter().enumerate() {
+ let username = format!("member-{index}");
+ let principal_blob = hash_object(&repo, username.as_bytes());
+ let key = std::fs::read_to_string(member.pubkey).unwrap();
+ let key_blob = hash_object(&repo, key.as_bytes());
+ let keys_tree = mktree(&repo, &format!("100644 blob {key_blob}\tkey\n"));
+ let trust_tree = mktree(&repo, &format!("040000 tree {keys_tree}\tKeys\n"));
+ let after_tree = option_tree(member.valid_after);
+ let before_tree = option_tree(member.valid_before);
+ let root_tree = mktree(
+ &repo,
+ &format!(
+ "100644 blob {principal_blob}\tprincipal\n\
+ 040000 tree {after_tree}\tvalid_after\n\
+ 040000 tree {before_tree}\tvalid_before\n\
+ 040000 tree {trust_tree}\ttrust\n"
+ ),
+ );
+ let commit = ok(&repo, "git", &["commit-tree", &root_tree, "-m", "member"]);
+ ok(
+ &repo,
+ "git",
+ &[
+ "update-ref",
+ &format!("refs/meta/member/{username}"),
+ &commit,
+ ],
+ );
}
repo
}
crates/git-ents/src/lib.rs
@@ -1,5 +1,6 @@
//! Git Ents — helpful guardians of your git trees.
+pub mod account;
pub mod checks;
pub mod config;
pub mod issues;
crates/git-ents/src/main.rs
@@ -1,17 +1,21 @@
//! `git ents` — the git-ents command-line porcelain.
//!
-//! Today it carries a single command, `git ents members`, for managing the
-//! repository members recorded at `refs/meta/members` and for configuring this
-//! client to produce the signed pushes the server requires. The member commands
-//! read and write a remote's set by fetching `refs/meta/members` into the local
-//! repository, editing it through [`git_ents::signers`], and pushing it back.
+//! It carries `git ents members` for managing the repository members recorded
+//! one-ref-per-person at `refs/meta/member/<username>`, `git ents account` for
+//! the account identity at `refs/meta/account`, `git ents checks` for the check
+//! set, and the client setup that produces the signed pushes the server
+//! requires. The member commands read and write a remote's set by fetching the
+//! `refs/meta/member/*` refs into the local repository, editing them through
+//! [`git_ents::signers`], and pushing them back.
+use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use clap::{Parser, Subcommand};
+use git_ents::account::{self, Account};
use git_ents::checks::{self, CHECKS_REF, Check};
-use git_ents::signers::{self, MEMBERS_REF, Signer};
+use git_ents::signers::{self, MEMBER_NS, Member, Trust, member_ref};
#[derive(Parser)]
#[command(name = "git-ents", about = "Helpful guardians of your git trees.")]
@@ -22,11 +26,16 @@
#[derive(Subcommand)]
enum Top {
- /// Manage the repository members at `refs/meta/members`.
+ /// Manage the repository members at `refs/meta/member/<username>`.
Members {
#[command(subcommand)]
action: Action,
},
+ /// Manage this repository's account identity at `refs/meta/account`.
+ Account {
+ #[command(subcommand)]
+ action: AccountAction,
+ },
/// Manage the configured checks at `refs/meta/checks`.
Checks {
#[command(subcommand)]
@@ -48,38 +57,41 @@
},
/// List the members on a remote.
List {
- /// Remote to read `refs/meta/members` from.
+ /// Remote to read the `refs/meta/member/*` refs from.
#[arg(default_value = "origin")]
remote: String,
},
- /// Add a member to a remote's set and push the update.
+ /// Authorize a key for a member on a remote and push the update.
Add {
- /// Remote whose `refs/meta/members` to update.
+ /// Member (username) to authorize the key under — its
+ /// `refs/meta/member/<username>` ref.
+ username: String,
+ /// Remote whose member refs to update.
#[arg(default_value = "origin")]
remote: String,
/// Key to authorize; defaults to `user.signingkey`.
#[arg(long)]
key: Option<PathBuf>,
- /// Trust the key only at or after this OpenSSH timestamp
+ /// Trust the member only at or after this OpenSSH timestamp
/// (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`; append `Z` for UTC).
#[arg(long, value_name = "TIMESTAMP")]
valid_after: Option<String>,
- /// Stop trusting the key after this OpenSSH timestamp; omit for trust
+ /// Stop trusting the member after this OpenSSH timestamp; omit for trust
/// that never lapses on its own.
#[arg(long, value_name = "TIMESTAMP")]
valid_before: Option<String>,
},
- /// Remove a member from a remote's set and push the update.
+ /// Revoke a member, deleting its ref on a remote and pushing the update.
Remove {
- /// Fingerprint (`members/<name>`) to drop.
- fingerprint: String,
- /// Remote whose `refs/meta/members` to update.
+ /// Member (username) to revoke — its `refs/meta/member/<username>` ref.
+ username: String,
+ /// Remote whose member ref to delete.
#[arg(default_value = "origin")]
remote: String,
},
/// Report whether a key is a member and the client is configured.
Check {
- /// Remote to read `refs/meta/members` from.
+ /// Remote to read the `refs/meta/member/*` refs from.
#[arg(default_value = "origin")]
remote: String,
/// Key to look for; defaults to `user.signingkey`.
@@ -88,6 +100,25 @@
},
}
+#[derive(Subcommand)]
+enum AccountAction {
+ /// Create or update this repository's account identity and push it. The
+ /// presence of `refs/meta/account` is what marks the repo as an account.
+ Create {
+ /// The account username — by convention the `user/<username>` repo name.
+ username: String,
+ /// Remote whose `refs/meta/account` to update.
+ #[arg(default_value = "origin")]
+ remote: String,
+ /// Human-facing display name; defaults to the username.
+ #[arg(long)]
+ display_name: Option<String>,
+ /// Short free-text bio.
+ #[arg(long, default_value = "")]
+ bio: String,
+ },
+}
+
#[derive(Subcommand)]
enum ChecksAction {
/// List the checks configured on a remote.
@@ -120,6 +151,7 @@
let cli = Cli::parse();
let result = match cli.command {
Top::Members { action } => run_members(action),
+ Top::Account { action } => run_account(action),
Top::Checks { action } => run_checks(action),
};
match result {
@@ -134,21 +166,36 @@
fn run_members(action: Action) -> Result<(), String> {
match action {
Action::Setup { key, local } => setup(key.as_deref(), local),
- Action::List { remote } => list::<Signers>(&remote),
+ Action::List { remote } => members_list(&remote),
Action::Add {
+ username,
remote,
key,
valid_after,
valid_before,
- } => add(&remote, key.as_deref(), valid_after, valid_before),
- Action::Remove {
- fingerprint,
- remote,
- } => remove::<Signers>(&fingerprint, &remote),
+ } => members_add(
+ &username,
+ &remote,
+ key.as_deref(),
+ valid_after,
+ valid_before,
+ ),
+ Action::Remove { username, remote } => members_remove(&username, &remote),
Action::Check { remote, key } => check(&remote, key.as_deref()),
}
}
+fn run_account(action: AccountAction) -> Result<(), String> {
+ match action {
+ AccountAction::Create {
+ username,
+ remote,
+ display_name,
+ bio,
+ } => account_create(&username, &remote, display_name, bio),
+ }
+}
+
fn run_checks(action: ChecksAction) -> Result<(), String> {
match action {
ChecksAction::List { remote } => list::<Checks>(&remote),
@@ -163,10 +210,10 @@
/// A `refs/meta/*` set the porcelain manages uniformly: a named ref synced from
/// and pushed to a remote, holding entries the CLI lists and removes from. The
-/// two sets — authorized signers and configured checks — share that flow and
-/// differ only in their item type, what the messages call an entry, and how a
-/// row presents; the thin [`load`](Set::load)/[`store`](Set::store) keep each on
-/// its own typed module.
+/// check set runs through this; the member set is decomposed across
+/// `refs/meta/member/*` and handled on its own. A set differs only in its item
+/// type, what the messages call an entry, and how a row presents; the thin
+/// [`load`](Set::load)/[`store`](Set::store) keep each on its own typed module.
trait Set {
/// The set's item type.
type Item;
@@ -187,35 +234,6 @@
fn value(item: &Self::Item) -> String;
}
-/// The repository member set at `refs/meta/members`.
-struct Signers;
-
-impl Set for Signers {
- type Item = Signer;
- const REF: &'static str = MEMBERS_REF;
- const NOUN: &'static str = "member";
-
- fn load(repo: &Path) -> Result<Vec<Signer>, String> {
- signers::load(repo).map_err(|error| error.to_string())
- }
-
- fn store(repo: &Path, items: &[Signer]) -> Result<(), String> {
- signers::store(repo, items).map_err(|error| error.to_string())
- }
-
- fn empty_listing(remote: &str) -> String {
- format!("no members on {remote} (open bootstrap window)")
- }
-
- fn key(item: &Signer) -> String {
- item.fingerprint.clone()
- }
-
- fn value(item: &Signer) -> String {
- signer_label(item)
- }
-}
-
/// The configured check set at `refs/meta/checks`.
struct Checks;
@@ -245,25 +263,22 @@
}
}
-/// The list column for a member: its key comment and validity window, so an
-/// expiry that has been set is visible at a glance rather than hidden in the
-/// stored `allowed_signers` options.
-fn signer_label(signer: &Signer) -> String {
- let mut label = key_comment(&signer.key);
+/// The trailing ` (after …, before …)` annotation for a member's validity
+/// window, or `""` when unbounded — so an expiry that has been set is visible at
+/// a glance rather than hidden in the stored `allowed_signers` options.
+fn window_suffix(member: &Member) -> String {
let mut window = Vec::new();
- if let Some(after) = &signer.valid_after {
+ if let Some(after) = &member.valid_after {
window.push(format!("after {after}"));
}
- if let Some(before) = &signer.valid_before {
+ if let Some(before) = &member.valid_before {
window.push(format!("before {before}"));
}
- if !window.is_empty() {
- if !label.is_empty() {
- label.push(' ');
- }
- label.push_str(&format!("({})", window.join(", ")));
+ if window.is_empty() {
+ String::new()
+ } else {
+ format!(" ({})", window.join(", "))
}
- label
}
/// Print each entry of the set `S` on `remote` as `<key> <value>`.
@@ -463,9 +478,33 @@
Ok(Path::new(&home).join(".ssh").join("id_ed25519"))
}
-/// Authorize `key` on `remote`, trusting it within the given validity window,
-/// and push the updated set.
-fn add(
+/// List every member on `remote` — one line per authorized key — as
+/// `<username>/<fingerprint> <key label><window>`.
+fn members_list(remote: &str) -> Result<(), String> {
+ let repo = repo()?;
+ sync_namespace(remote, MEMBER_NS)?;
+ let members = signers::load_all(&repo).map_err(|error| error.to_string())?;
+ if members.is_empty() {
+ println!("no members on {remote} (open bootstrap window)");
+ return Ok(());
+ }
+ for member in members {
+ let suffix = window_suffix(&member);
+ for (fingerprint, key) in member.keys() {
+ println!(
+ "{}/{fingerprint} {}{suffix}",
+ member.principal,
+ key_comment(key)
+ );
+ }
+ }
+ Ok(())
+}
+
+/// Authorize `key` for the member `username` on `remote`, trusting the member
+/// within the given validity window, and push the updated member ref.
+fn members_add(
+ username: &str,
remote: &str,
key: Option<&Path>,
valid_after: Option<String>,
@@ -480,27 +519,74 @@
let repo = repo()?;
let public_key = public_key(key)?;
let fingerprint = fingerprint(&public_key)?;
- let expected = sync(remote, MEMBERS_REF)?;
- let mut signers = signers::load(&repo).map_err(|error| error.to_string())?;
- if signers
- .iter()
- .any(|signer| same_key(&signer.key, &public_key))
+ let refname = member_ref(username);
+ let expected = sync(remote, &refname)?;
+ let mut member = signers::load(&repo, username)
+ .map_err(|error| error.to_string())?
+ .unwrap_or_else(|| Member::with_keys(username.to_owned(), BTreeMap::new()));
+ if valid_after.is_some() {
+ member.valid_after = valid_after;
+ }
+ if valid_before.is_some() {
+ member.valid_before = valid_before;
+ }
+ let Trust::Keys(keys) = &mut member.trust;
+ if keys
+ .values()
+ .any(|existing| same_key(existing, &public_key))
{
- println!("{fingerprint} is already a member");
+ println!("{fingerprint} is already authorized for {username}");
return Ok(());
}
- signers.push(Signer {
- fingerprint: fingerprint.clone(),
- key: public_key,
- valid_after,
- valid_before,
- });
- signers::store(&repo, &signers).map_err(|error| error.to_string())?;
- push_signed(remote, MEMBERS_REF, expected.as_deref())?;
- println!("authorized {fingerprint}");
+ keys.insert(fingerprint.clone(), public_key);
+ signers::store(&repo, &member).map_err(|error| error.to_string())?;
+ push_signed(remote, &refname, expected.as_deref())?;
+ println!("authorized {fingerprint} for {username}");
Ok(())
}
+/// Revoke the member `username` on `remote`, deleting its ref and pushing the
+/// deletion. Removal here is a plain signed delete; quorum-gated removal is a
+/// later server-side policy.
+fn members_remove(username: &str, remote: &str) -> Result<(), String> {
+ let refname = member_ref(username);
+ let expected =
+ sync(remote, &refname)?.ok_or_else(|| format!("no member named {username} on {remote}"))?;
+ push_delete(remote, &refname, &expected)?;
+ println!("revoked {username}");
+ Ok(())
+}
+
+/// Create or update this repository's account identity on `remote` and push it.
+fn account_create(
+ username: &str,
+ remote: &str,
+ display_name: Option<String>,
+ bio: String,
+) -> Result<(), String> {
+ let repo = repo()?;
+ let expected = sync(remote, account::ACCOUNT_REF)?;
+ let existing = account::load(&repo).map_err(|error| error.to_string())?;
+ let account = Account {
+ username: username.to_owned(),
+ display_name: display_name.unwrap_or_else(|| username.to_owned()),
+ bio,
+ // Preserve the original creation time when updating an existing account.
+ created_at: existing.map_or_else(now_seconds, |account| account.created_at),
+ };
+ account::store(&repo, &account).map_err(|error| error.to_string())?;
+ push_signed(remote, account::ACCOUNT_REF, expected.as_deref())?;
+ println!("created account {username}");
+ Ok(())
+}
+
+/// The current time as seconds since the Unix epoch.
+fn now_seconds() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |elapsed| elapsed.as_secs())
+}
+
/// Check that `value` is an OpenSSH `allowed_signers` timestamp: `YYYYMMDD`,
/// `YYYYMMDDHHMM`, or `YYYYMMDDHHMMSS`, each optionally suffixed `Z` for UTC.
/// Without `Z` the verifying server reads it in its own local time zone.
@@ -517,20 +603,23 @@
}
}
-/// Report whether `key` is in `remote`'s set and how this client is configured.
+/// Report whether `key` is a member on `remote` and how this client is
+/// configured.
fn check(remote: &str, key: Option<&Path>) -> Result<(), String> {
let repo = repo()?;
let public_key = public_key(key)?;
let fingerprint = fingerprint(&public_key)?;
- sync(remote, MEMBERS_REF)?;
- let signers = signers::load(&repo).map_err(|error| error.to_string())?;
- if signers.is_empty() {
+ sync_namespace(remote, MEMBER_NS)?;
+ let members = signers::load_all(&repo).map_err(|error| error.to_string())?;
+ if members.is_empty() {
println!("{remote}: open bootstrap window (no members yet)");
- } else if signers
- .iter()
- .any(|signer| same_key(&signer.key, &public_key))
- {
- println!("{remote}: {fingerprint} is a member");
+ } else if let Some(member) = members.iter().find(|member| {
+ member
+ .keys()
+ .iter()
+ .any(|(_fp, k)| same_key(k, &public_key))
+ }) {
+ println!("{remote}: {fingerprint} is a member ({})", member.principal);
} else {
println!("{remote}: {fingerprint} is NOT a member");
}
@@ -566,6 +655,14 @@
Ok(oid)
}
+/// Mirror every ref under `remote`'s `namespace` (e.g. `refs/meta/member`) into
+/// the local repository, pruning local refs the remote no longer has, so the
+/// glob helpers see the remote's current set.
+fn sync_namespace(remote: &str, namespace: &str) -> Result<(), String> {
+ let refspec = format!("+{namespace}/*:{namespace}/*");
+ git_run(&["fetch", "--quiet", "--prune", remote, &refspec])
+}
+
/// Push the local `refname` to `remote`, signed per the client's config.
///
/// `expected` is the remote tip observed at sync time (`None` when the ref did
@@ -580,6 +677,15 @@
git_run(&["push", "--force-if-includes", &lease, remote, refname])
}
+/// Delete `refname` on `remote`, signed per the client's config and pinned with
+/// `--force-with-lease` to the `expected` tip so a member changed since the
+/// fetch is not clobbered.
+fn push_delete(remote: &str, refname: &str, expected: &str) -> Result<(), String> {
+ let lease = format!("--force-with-lease={refname}:{expected}");
+ let refspec = format!(":{refname}");
+ git_run(&["push", "--force-if-includes", &lease, remote, &refspec])
+}
+
/// Resolve the OpenSSH public key to operate on, defaulting to the key behind
/// `user.signingkey`.
fn public_key(key: Option<&Path>) -> Result<String, String> {
crates/git-ents/src/signers.rs
@@ -1,18 +1,19 @@
-//! The repository's members, sourced from the `refs/meta/members` ref.
+//! The repository's members, sourced from the `refs/meta/member/*` refs.
//!
-//! Push authentication trusts exactly one place: the `refs/meta/members` ref.
-//! Its tree is a [`Members`] document mapping each fingerprint to the OpenSSH
-//! public key held there and the validity window it is trusted within. A member
-//! *is* one or more keys whose signed pushes are accepted while in window. The
-//! document is read and written through [`git_store`], so the trust list is a
-//! typed value that lives in git — versioned, auditable, and itself pushable.
+//! Push authentication trusts exactly one place: the `refs/meta/member/<username>`
+//! refs. Each is a [`Member`] document — one person, named by the ref's last
+//! segment — recording the keys whose signed pushes are accepted and the window
+//! that trust holds within. The set is decomposed, one ref per person, rather
+//! than a single aggregated blob, so a member can be added, refreshed, or revoked
+//! as an independent, separately-history'd ref. The verifier unions every
+//! `refs/meta/member/*` into the trust list.
//!
//! # Expiry
//!
-//! Each key carries an optional `valid-after`/`valid-before` window rendered
-//! into the `allowed_signers` file git verifies pushes against. The window is
-//! the security primitive: an un-refreshed key stops authorizing *new* pushes
-//! once it lapses, so stale trust fails closed. A previously-valid push stays
+//! Each member carries an optional `valid-after`/`valid-before` window rendered
+//! into the `allowed_signers` file git verifies pushes against. The window is the
+//! security primitive: an un-refreshed member stops authorizing *new* pushes once
+//! it lapses, so stale trust fails closed. A previously-valid push stays
//! verifiable forever, since `ssh-keygen -Y verify -Overify-time` can pin the
//! check to the time the push was made.
@@ -21,118 +22,135 @@
use facet::Facet;
-/// The ref whose tree holds the member set — the push trust root.
-pub const MEMBERS_REF: &str = "refs/meta/members";
+/// The namespace whose refs hold the member set — the push trust root. One
+/// `refs/meta/member/<username>` ref per person.
+pub const MEMBER_NS: &str = "refs/meta/member";
-/// The membership document stored at [`MEMBERS_REF`]: its `members/` subtree
-/// maps each fingerprint to the [`Authorization`] held under it.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct Members {
- members: BTreeMap<String, Authorization>,
+/// The ref holding the member named `username`.
+#[must_use]
+pub fn member_ref(username: &str) -> String {
+ format!("{MEMBER_NS}/{username}")
}
-/// One fingerprint's stored authorization: the OpenSSH public key it names and
-/// the window that key is trusted within. The fingerprint is the map key, so it
-/// is not repeated here.
+/// One member: a person named by their `refs/meta/member/<principal>` ref, the
+/// window their trust holds within, and the keys (or, later, CA) it rests on.
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct Authorization {
- /// The OpenSSH public key the member signs with (`<type> <base64> [comment]`).
- key: String,
- /// The key is trusted at or after this OpenSSH timestamp; `None` is no lower
- /// bound.
- valid_after: Option<String>,
- /// The key is trusted at or before this OpenSSH timestamp; `None` is no upper
- /// bound — trust that never lapses on its own.
- valid_before: Option<String>,
-}
-
-/// One member's authorized signing key recorded in [`MEMBERS_REF`], with the
-/// validity window it is trusted within.
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Signer {
- /// The key it is stored under — its fingerprint.
- pub fingerprint: String,
- /// The OpenSSH public key the blob holds (`<type> <base64> [comment]`).
- pub key: String,
+pub struct Member {
+ /// The member's username — the ref's last segment, and the `allowed_signers`
+ /// principal once identities are bound to signing keys.
+ pub principal: String,
/// The OpenSSH timestamp (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`) at or after
- /// which the key is trusted, or `None` for no lower bound.
+ /// which the member is trusted, or `None` for no lower bound.
pub valid_after: Option<String>,
- /// The OpenSSH timestamp at or before which the key is trusted, or `None` for
- /// trust that never lapses on its own.
+ /// The OpenSSH timestamp at or before which the member is trusted, or `None`
+ /// for trust that never lapses on its own.
pub valid_before: Option<String>,
+ /// What the member's trust rests on.
+ pub trust: Trust,
}
-/// Load the members recorded at [`MEMBERS_REF`] in `repo`.
+/// What a member's trust rests on. A member is *either* a set of leaf keys *or*
+/// (from Phase 3) a pinned certificate authority — additive cases, not a
+/// migration of one another.
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum Trust {
+ /// A set of leaf signing keys, mapping each fingerprint to its OpenSSH public
+ /// key.
+ Keys(BTreeMap<String, String>),
+}
+
+impl Member {
+ /// A member trusting `keys` with no validity window.
+ #[must_use]
+ pub fn with_keys(principal: String, keys: BTreeMap<String, String>) -> Self {
+ Self {
+ principal,
+ valid_after: None,
+ valid_before: None,
+ trust: Trust::Keys(keys),
+ }
+ }
+
+ /// The member's leaf signing keys as `(fingerprint, key)` pairs. A member
+ /// resting on a CA (Phase 3) has no leaf keys and yields none.
+ #[must_use]
+ pub fn keys(&self) -> Vec<(&String, &String)> {
+ match &self.trust {
+ Trust::Keys(keys) => keys.iter().collect(),
+ }
+ }
+}
+
+/// Load the member named `username` in `repo`, or `None` when the ref is absent.
+pub fn load(repo: &Path, username: &str) -> Result<Option<Member>, git_store::Error> {
+ git_store::Store::open(repo)?.load::<Member>(&member_ref(username))
+}
+
+/// Load every member recorded under [`MEMBER_NS`] in `repo`, newest ref first.
///
-/// An absent ref yields an empty set, as on a fresh server whose trust list has
-/// not been pushed yet. A present but unreadable ref is an error so callers can
-/// fail closed rather than mistake corruption for "no members".
-pub fn load(repo: &Path) -> Result<Vec<Signer>, git_store::Error> {
- let Some(doc) = git_store::Store::open(repo)?.load::<Members>(MEMBERS_REF)? else {
- return Ok(Vec::new());
- };
- Ok(doc
- .members
- .into_iter()
- .map(|(fingerprint, held)| Signer {
- fingerprint,
- key: held.key,
- valid_after: held.valid_after,
- valid_before: held.valid_before,
- })
- .collect())
+/// An empty result is a fresh server whose trust list has not been pushed yet. A
+/// present but unreadable member ref is an error so callers can fail closed
+/// rather than mistake corruption for "no members".
+pub fn load_all(repo: &Path) -> Result<Vec<Member>, git_store::Error> {
+ let store = git_store::Store::open(repo)?;
+ let mut members = Vec::new();
+ for refname in store.list(&format!("{MEMBER_NS}/"))? {
+ if let Some(member) = store.load::<Member>(&refname)? {
+ members.push(member);
+ }
+ }
+ Ok(members)
}
-/// Write `signers` to [`MEMBERS_REF`], replacing any existing set, as a new
-/// commit.
-pub fn store(repo: &Path, signers: &[Signer]) -> Result<(), git_store::Error> {
- let members = Members {
- members: signers
- .iter()
- .map(|signer| {
- (
- signer.fingerprint.clone(),
- Authorization {
- key: signer.key.clone(),
- valid_after: signer.valid_after.clone(),
- valid_before: signer.valid_before.clone(),
- },
- )
- })
- .collect(),
- };
- git_store::Store::open(repo)?.store(MEMBERS_REF, &members, "Update members")?;
+/// Write `member` to its `refs/meta/member/<principal>` ref, replacing any prior
+/// value, as a new commit.
+pub fn store(repo: &Path, member: &Member) -> Result<(), git_store::Error> {
+ git_store::Store::open(repo)?.store(&member_ref(&member.principal), member, "Update member")?;
Ok(())
}
-/// Render `signers` as an OpenSSH `allowed_signers` file that authorizes any
+/// Render `members` as an OpenSSH `allowed_signers` file that authorizes any
/// pusher identity (`*`) signing in git's namespace.
///
/// The principal is a wildcard because authentication here is membership of the
/// key set, not a binding between a key and a particular identity: `ssh-keygen
/// -Y verify` accepts the push certificate as long as the signing key is one of
-/// these, whatever name the pusher signed under.
+/// these, whatever name the pusher signed under. The member's username lives in
+/// the ref and the `principal` field; binding it to the signing principal is a
+/// later identity concern.
///
-/// Each key's `valid-after`/`valid-before` window is rendered as `allowed_signers`
-/// options so git enforces expiry: out-of-window keys are not accepted. Options
-/// are comma-joined, the syntax OpenSSH requires for more than one.
+/// Each member's `valid-after`/`valid-before` window is rendered as
+/// `allowed_signers` options so git enforces expiry: out-of-window keys are not
+/// accepted. Options are comma-joined, the syntax OpenSSH requires for more than
+/// one.
#[must_use]
-pub fn allowed_signers(signers: &[Signer]) -> String {
- signers.iter().map(allowed_signers_line).collect()
+pub fn allowed_signers(members: &[Member]) -> String {
+ members.iter().flat_map(member_lines).collect::<String>()
}
-/// One `allowed_signers` line for `signer`: the wildcard principal, its validity
+/// The `allowed_signers` lines for one member: one per leaf key, each carrying
+/// the member's validity window.
+fn member_lines(member: &Member) -> Vec<String> {
+ member
+ .keys()
+ .into_iter()
+ .map(|(_fingerprint, key)| allowed_signers_line(member, key))
+ .collect()
+}
+
+/// One `allowed_signers` line: the wildcard principal, the member's validity
/// window, the git namespace, and the key.
-fn allowed_signers_line(signer: &Signer) -> String {
+fn allowed_signers_line(member: &Member, key: &str) -> String {
let mut options = Vec::new();
- if let Some(after) = &signer.valid_after {
+ if let Some(after) = &member.valid_after {
options.push(format!("valid-after=\"{after}\""));
}
- if let Some(before) = &signer.valid_before {
+ if let Some(before) = &member.valid_before {
options.push(format!("valid-before=\"{before}\""));
}
options.push("namespaces=\"git\"".to_owned());
- format!("* {} {}\n", options.join(","), signer.key)
+ format!("* {} {}\n", options.join(","), key)
}
#[cfg(test)]
@@ -144,7 +162,7 @@
)]
use super::*;
- use crate::testutil::{unique_repo as new_repo, write_members_doc};
+ use crate::testutil::{unique_repo as new_repo, write_member_doc};
const KEY_A: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA alice";
@@ -155,83 +173,100 @@
new_repo("signers")
}
- fn signer(fingerprint: &str, key: &str) -> Signer {
- Signer {
- fingerprint: fingerprint.to_owned(),
- key: key.to_owned(),
- valid_after: None,
- valid_before: None,
- }
+ fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
+ pairs
+ .iter()
+ .map(|(fp, key)| ((*fp).to_owned(), (*key).to_owned()))
+ .collect()
}
#[test]
- fn store_then_load_round_trips_the_signer_set() {
+ fn store_then_load_round_trips_a_member() {
let repo = unique_repo();
- let mut bounded = signer("SHA256-bbb", KEY_B);
- bounded.valid_after = Some("20260101".to_owned());
- bounded.valid_before = Some("20270101".to_owned());
- let written = vec![signer("SHA256-aaa", KEY_A), bounded];
- store(&repo, &written).unwrap();
-
- let mut loaded = load(&repo).unwrap();
- loaded.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
- assert_eq!(loaded, written);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn store_replaces_the_previous_set() {
- let repo = unique_repo();
- store(&repo, &[signer("SHA256-aaa", KEY_A)]).unwrap();
- store(&repo, &[signer("SHA256-bbb", KEY_B)]).unwrap();
- assert_eq!(load(&repo).unwrap(), vec![signer("SHA256-bbb", KEY_B)]);
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn empty_when_the_members_ref_is_absent() {
- let repo = unique_repo();
- assert!(load(&repo).unwrap().is_empty());
- let _ = std::fs::remove_dir_all(&repo);
- }
-
- #[test]
- fn loads_the_on_disk_members_format() {
- // A fixture written as the real `members/<fingerprint>/{key,valid_after,
- // valid_before}` layout must keep loading; this fails if the Members
- // document's shape changes incompatibly with data already on a ref.
- let repo = unique_repo();
- write_members_doc(
- &repo,
- MEMBERS_REF,
- &[
- ("aa:bb:cc", KEY_A, None, None),
- ("dd:ee:ff", KEY_B, None, Some("20270101")),
- ],
+ let mut member = Member::with_keys(
+ "alice".to_owned(),
+ keys(&[("aa:bb", KEY_A), ("cc:dd", KEY_B)]),
+ );
+ member.valid_after = Some("20260101".to_owned());
+ member.valid_before = Some("20270101".to_owned());
+ store(&repo, &member).unwrap();
+
+ assert_eq!(load(&repo, "alice").unwrap(), Some(member));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn load_all_unions_every_member_ref() {
+ let repo = unique_repo();
+ store(
+ &repo,
+ &Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])),
+ )
+ .unwrap();
+ store(
+ &repo,
+ &Member::with_keys("bob".to_owned(), keys(&[("cc:dd", KEY_B)])),
+ )
+ .unwrap();
+
+ let mut principals: Vec<String> = load_all(&repo)
+ .unwrap()
+ .into_iter()
+ .map(|member| member.principal)
+ .collect();
+ principals.sort();
+ assert_eq!(principals, vec!["alice".to_owned(), "bob".to_owned()]);
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn empty_when_no_member_refs_exist() {
+ let repo = unique_repo();
+ assert!(load_all(&repo).unwrap().is_empty());
+ assert_eq!(load(&repo, "nobody").unwrap(), None);
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn loads_the_on_disk_member_format() {
+ // A fixture written as the real `member/<username>` layout — a `principal`
+ // blob, `valid_after`/`valid_before` Option subtrees, and a
+ // `trust/Keys/<fingerprint>` subtree — must keep loading; this fails if
+ // the Member document's shape changes incompatibly with data on a ref.
+ let repo = unique_repo();
+ write_member_doc(
+ &repo,
+ "alice",
+ None,
+ Some("20270101"),
+ &[("aa:bb:cc", KEY_A)],
+ );
+ let member = load(&repo, "alice").unwrap().unwrap();
+ assert_eq!(member.principal, "alice");
+ assert_eq!(member.valid_before, Some("20270101".to_owned()));
+ assert_eq!(
+ member.keys(),
+ vec![(&"aa:bb:cc".to_owned(), &KEY_A.to_owned())]
);
- let mut loaded = load(&repo).unwrap();
- loaded.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
- let mut expected_b = signer("dd:ee:ff", KEY_B);
- expected_b.valid_before = Some("20270101".to_owned());
- assert_eq!(loaded, vec![signer("aa:bb:cc", KEY_A), expected_b]);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn renders_a_wildcard_allowed_signers_file() {
+ let member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)]));
assert_eq!(
- allowed_signers(&[signer("SHA256-aaa", KEY_A)]),
+ allowed_signers(&[member]),
format!("* namespaces=\"git\" {KEY_A}\n")
);
}
#[test]
fn renders_the_validity_window_as_comma_joined_options() {
- let mut bounded = signer("SHA256-aaa", KEY_A);
- bounded.valid_after = Some("20260101".to_owned());
- bounded.valid_before = Some("20270101".to_owned());
+ let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)]));
+ member.valid_after = Some("20260101".to_owned());
+ member.valid_before = Some("20270101".to_owned());
assert_eq!(
- allowed_signers(&[bounded]),
+ allowed_signers(&[member]),
format!(
"* valid-after=\"20260101\",valid-before=\"20270101\",namespaces=\"git\" {KEY_A}\n"
)
crates/git-ents/src/testutil.rs
@@ -70,15 +70,18 @@
assert!(status.success());
}
-/// Lay a `Members` document out at `refname` as the real on-disk format: a
-/// `members/<fingerprint>/` subtree per member holding a `key` blob and an
-/// `valid_after`/`valid_before` `Option` subtree each (empty tree for `None`, a
-/// single `some` blob for a bound). Asserts the loader still reads the format
-/// independent of the writer.
-pub(crate) fn write_members_doc(
+/// Lay a `Member` document out at `refs/meta/member/<username>` as the real
+/// on-disk format: a `principal` blob, `valid_after`/`valid_before` `Option`
+/// subtrees (empty tree for `None`, a single `some` blob for a bound), and a
+/// `trust/Keys/<fingerprint>` blob per key (the `Trust::Keys` newtype enum
+/// variant resolving directly to its map). Asserts the loader still reads the
+/// format independent of the writer.
+pub(crate) fn write_member_doc(
repo: &Path,
- refname: &str,
- members: &[(&str, &str, Option<&str>, Option<&str>)],
+ username: &str,
+ valid_after: Option<&str>,
+ valid_before: Option<&str>,
+ keys: &[(&str, &str)],
) {
let option_tree = |bound: Option<&str>| match bound {
None => git_with_stdin(repo, &["mktree"], ""),
@@ -87,33 +90,72 @@
git_with_stdin(repo, &["mktree"], &format!("100644 blob {blob}\tsome\n"))
}
};
- let mut member_entries = String::new();
- for (fingerprint, key, valid_after, valid_before) in members {
+ let principal_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], username);
+ let after_tree = option_tree(valid_after);
+ let before_tree = option_tree(valid_before);
+ let mut key_entries = String::new();
+ for (fingerprint, key) in keys {
let key_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], key);
- let after_tree = option_tree(*valid_after);
- let before_tree = option_tree(*valid_before);
- let member_tree = git_with_stdin(
- repo,
- &["mktree"],
- &format!(
- "100644 blob {key_blob}\tkey\n\
- 040000 tree {after_tree}\tvalid_after\n\
- 040000 tree {before_tree}\tvalid_before\n"
- ),
- );
- member_entries.push_str(&format!("040000 tree {member_tree}\t{fingerprint}\n"));
+ key_entries.push_str(&format!("100644 blob {key_blob}\t{fingerprint}\n"));
}
- let members_tree = git_with_stdin(repo, &["mktree"], &member_entries);
+ let keys_tree = git_with_stdin(repo, &["mktree"], &key_entries);
+ let trust_tree = git_with_stdin(
+ repo,
+ &["mktree"],
+ &format!("040000 tree {keys_tree}\tKeys\n"),
+ );
let root = git_with_stdin(
repo,
&["mktree"],
- &format!("040000 tree {members_tree}\tmembers\n"),
+ &format!(
+ "100644 blob {principal_blob}\tprincipal\n\
+ 040000 tree {after_tree}\tvalid_after\n\
+ 040000 tree {before_tree}\tvalid_before\n\
+ 040000 tree {trust_tree}\ttrust\n"
+ ),
+ );
+ let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
+ let refname = format!("refs/meta/member/{username}");
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(["update-ref", &refname, &commit])
+ .status()
+ .unwrap();
+ assert!(status.success());
+}
+
+/// Lay an `Account` document out at `refs/meta/account` as the real on-disk
+/// format: `username`, `display_name`, `bio`, and `created_at` blobs (the
+/// integer in its decimal `Display` form). Asserts the loader still reads the
+/// format independent of the writer.
+pub(crate) fn write_account_doc(
+ repo: &Path,
+ username: &str,
+ display_name: &str,
+ bio: &str,
+ created_at: u64,
+) {
+ let blob = |value: &str| git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value);
+ let username_blob = blob(username);
+ let display_blob = blob(display_name);
+ let bio_blob = blob(bio);
+ let created_blob = blob(&created_at.to_string());
+ let root = git_with_stdin(
+ repo,
+ &["mktree"],
+ &format!(
+ "100644 blob {created_blob}\tcreated_at\n\
+ 100644 blob {display_blob}\tdisplay_name\n\
+ 100644 blob {bio_blob}\tbio\n\
+ 100644 blob {username_blob}\tusername\n"
+ ),
);
let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], "");
let status = Command::new("git")
.arg("-C")
.arg(repo)
- .args(["update-ref", refname, &commit])
+ .args(["update-ref", "refs/meta/account", &commit])
.status()
.unwrap();
assert!(status.success());
crates/git-ents-server/src/web/pages.rs
@@ -866,7 +866,7 @@
@if let Ok(signers) = &signers { span.count { (signers.len()) } }
}
p.shell-note {
- "Keys on " code { "refs/meta/members" } " whose signed pushes are accepted "
+ "People on " code { "refs/meta/member/*" } " whose signed pushes are accepted "
"(" code { "git ents members list" } ")."
}
@match &signers {
@@ -877,8 +877,8 @@
}
}
Ok(signers) => {
- @for signer in signers {
- (signer.render())
+ @for member in signers {
+ (member.render())
}
}
}
@@ -910,11 +910,11 @@
)
}
-/// Load the authorized signer set off the async runtime, since `signers::load`
-/// shells out to git and reads the object database synchronously.
-async fn load_signers(repo: &Path) -> Result<Vec<git_ents::signers::Signer>, String> {
+/// Load the member set off the async runtime, since `signers::load_all` shells
+/// out to git and reads the object database synchronously.
+async fn load_signers(repo: &Path) -> Result<Vec<git_ents::signers::Member>, String> {
let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents::signers::load(&repo))
+ tokio::task::spawn_blocking(move || git_ents::signers::load_all(&repo))
.await
.map_err(|err| err.to_string())?
.map_err(|err| err.to_string())
crates/git-ents-server/src/web/render.rs
@@ -14,7 +14,7 @@
use git_ents::checks::{Check, Run};
use git_ents::config::Config;
use git_ents::issues::Issue;
-use git_ents::signers::Signer;
+use git_ents::signers::Member;
/// HTML rendering for a meta-ref value. The default walks the value's [`Facet`]
/// shape structurally; a type overrides [`render`](Render::render) when its
@@ -47,11 +47,16 @@
}
}
-/// A signer's stored key is too long for a row, so show a short label beside the
-/// fingerprint instead of the raw key the structural walk would print.
-impl Render for Signer {
+/// A member renders one row per authorized key — the username as the key column,
+/// a short key label beside it — rather than the raw keys and trust enum the
+/// structural walk would print.
+impl Render for Member {
fn render(&self) -> Markup {
- row(&self.fingerprint, &signer_label(&self.key))
+ html! {
+ @for (_fingerprint, key) in self.keys() {
+ (row(&self.principal, &signer_label(key)))
+ }
+ }
}
}
crates/git-ents/src/account.rs
@@ -1,0 +1,109 @@
+//! The repository's account identity, sourced from the `refs/meta/account` ref.
+//!
+//! An *account* is just a repository that carries a `refs/meta/account` ref:
+//! identity is a repo, not a row in a central table. By convention an account
+//! repo is named `user/<username>`, but the trust never rests on that path —
+//! move the repo, keep the identity. The presence of the ref is what marks a
+//! repository as an account; its [`Account`] document carries the profile. This
+//! is the did:web-shaped identity the member refs will eventually `@`-mention.
+
+use std::path::Path;
+
+use facet::Facet;
+
+/// The ref whose tree holds the account profile, and whose mere presence marks a
+/// repository as an account repo.
+pub const ACCOUNT_REF: &str = "refs/meta/account";
+
+/// A repository's account profile, stored at [`ACCOUNT_REF`].
+#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
+pub struct Account {
+ /// The account's username — by convention the `user/<username>` repo name,
+ /// but authoritative here rather than in the path.
+ pub username: String,
+ /// The human-facing display name; defaults to the username.
+ pub display_name: String,
+ /// A short free-text bio; `""` when unset.
+ pub bio: String,
+ /// When the account was created, as seconds since the Unix epoch.
+ pub created_at: u64,
+}
+
+/// Load the account profile at [`ACCOUNT_REF`] in `repo`, or `None` when the ref
+/// is absent — i.e. when `repo` is not an account repo.
+pub fn load(repo: &Path) -> Result<Option<Account>, git_store::Error> {
+ git_store::Store::open(repo)?.load::<Account>(ACCOUNT_REF)
+}
+
+/// Write `account` to [`ACCOUNT_REF`], replacing any existing value, as a new
+/// commit.
+pub fn store(repo: &Path, account: &Account) -> Result<(), git_store::Error> {
+ git_store::Store::open(repo)?.store(ACCOUNT_REF, account, "Update account")?;
+ Ok(())
+}
+
+/// Whether `repo` is an account repo — whether it carries [`ACCOUNT_REF`].
+pub fn is_account_repo(repo: &Path) -> Result<bool, git_store::Error> {
+ Ok(load(repo)?.is_some())
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::let_underscore_must_use,
+ reason = "unit test"
+ )]
+
+ use super::*;
+ use crate::testutil::{unique_repo as new_repo, write_account_doc};
+
+ fn unique_repo() -> std::path::PathBuf {
+ new_repo("account")
+ }
+
+ fn account() -> Account {
+ Account {
+ username: "alice".to_owned(),
+ display_name: "Alice".to_owned(),
+ bio: "builder of trees".to_owned(),
+ created_at: 1_700_000_000,
+ }
+ }
+
+ #[test]
+ fn store_then_load_round_trips_the_account() {
+ let repo = unique_repo();
+ store(&repo, &account()).unwrap();
+ assert_eq!(load(&repo).unwrap(), Some(account()));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn absent_account_ref_is_not_an_account_repo() {
+ let repo = unique_repo();
+ assert_eq!(load(&repo).unwrap(), None);
+ assert!(!is_account_repo(&repo).unwrap());
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn the_account_ref_marks_an_account_repo() {
+ let repo = unique_repo();
+ store(&repo, &account()).unwrap();
+ assert!(is_account_repo(&repo).unwrap());
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn loads_the_on_disk_account_format() {
+ // A fixture written as the real on-disk layout — `username`,
+ // `display_name`, `bio`, and `created_at` blobs — must keep loading,
+ // guarding the Account document's shape against an incompatible change to
+ // data already on a ref.
+ let repo = unique_repo();
+ write_account_doc(&repo, "alice", "Alice", "builder of trees", 1_700_000_000);
+ assert_eq!(load(&repo).unwrap(), Some(account()));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+}