refactor: store the signer set as a facet-git-tree document
commit 60d511f
refactor: store the signer set as a facet-git-tree document
Load and write refs/meta/auth as an Auth { signers: BTreeMap } value via
facet-git-tree backed by a gix-odb handle, replacing the hand-rolled ls-tree /
cat-file reader. The on-disk shape is unchanged (signers/<fingerprint> blobs),
so pushed trust lists still read. load now returns a Result and the verifier
fails closed when a present ref is unreadable. The hook reads the common object
store, not the receive-pack quarantine, so it authorizes against the pre-push
set. Nothing publishes yet, so the workspace is marked publish = false,
clearing the git-dependency and pre-release constraints.
feat: add signers::store to write the signer set with facet-git-tree
feat: return a Result from signers::load so callers fail closed
refactor: read the signer set with facet-git-tree over a gix-odb handle
build: depend on facet, facet-git-tree, and gix-odb; align gix-object to 0.61
build: set publish = false workspace-wide
Assisted-by: Claude:claude-opus-4-8
crates/git-ents-server/src/verify.rs
@@ -19,7 +19,8 @@
/// environment git populates for the hook.
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);
+ let authorized =
+ signers::load(&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(());
crates/git-ents/src/signers.rs
@@ -1,16 +1,28 @@
//! The authorized signer set, sourced from the `refs/meta/auth` ref.
//!
-//! Push authentication trusts exactly one place: the `refs/meta/auth` ref. Each
-//! blob under its `signers/` tree is one authorized OpenSSH public key, stored
-//! under a name that is the key's fingerprint. Because the set lives in a ref,
-//! the trust list is versioned, auditable, and itself pushable.
+//! Push authentication trusts exactly one place: the `refs/meta/auth` ref. Its
+//! tree is an [`Auth`] document whose `signers/` subtree maps each fingerprint
+//! to its OpenSSH public key. The document is read and written with
+//! [`facet_git_tree`], so the trust list is a typed value that lives in git —
+//! versioned, auditable, and itself pushable.
+use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
+use facet::Facet;
+use facet_git_tree::ObjectId;
+
/// The ref whose tree holds the authorized signer set.
pub const AUTH_REF: &str = "refs/meta/auth";
+/// The authorization document stored at [`AUTH_REF`]: `signers/<fingerprint>`
+/// maps to the OpenSSH public key held there.
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+struct Auth {
+ signers: BTreeMap<String, String>,
+}
+
/// One authorized signer recorded under `signers/` in [`AUTH_REF`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signer {
@@ -20,27 +32,56 @@
pub key: String,
}
-/// Load the authorized signers recorded at [`AUTH_REF`] in the repository at
-/// `repo`.
+/// A failure reading or writing the signer set.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The repository's object database could not be opened.
+ #[error("could not open the repository object database")]
+ Odb,
+ /// The signer set could not be (de)serialized from its git tree.
+ #[error("could not (de)serialize the signer set: {0}")]
+ Facet(#[from] facet_git_tree::Error),
+ /// A git invocation needed to read or update the ref failed.
+ #[error("git {operation} failed")]
+ Git {
+ /// The git operation that failed.
+ operation: &'static str,
+ },
+}
+
+/// Load the authorized signers recorded at [`AUTH_REF`] in `repo`.
///
-/// Returns an empty set when the ref or its `signers/` tree is absent, as on a
-/// fresh server whose trust list has not been pushed yet.
-#[must_use]
-pub fn load(repo: &Path) -> Vec<Signer> {
- let Some(listing) = git(repo, &["ls-tree", &format!("{AUTH_REF}:signers")]) else {
- return Vec::new();
+/// 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 signers".
+pub fn load(repo: &Path) -> Result<Vec<Signer>, Error> {
+ let Some(tree) = auth_tree(repo) else {
+ return Ok(Vec::new());
};
- listing
- .lines()
- .filter_map(parse_blob_entry)
- .filter_map(|(oid, fingerprint)| {
- let key = git(repo, &["cat-file", "blob", oid])?;
- Some(Signer {
- fingerprint: fingerprint.to_owned(),
- key: key.trim_end().to_owned(),
- })
+ let odb = open_odb(repo).ok_or(Error::Odb)?;
+ let auth: Auth = facet_git_tree::deserialize(&tree, &odb)?;
+ Ok(auth
+ .signers
+ .into_iter()
+ .map(|(fingerprint, key)| Signer {
+ fingerprint,
+ key: key.trim_end().to_owned(),
})
- .collect()
+ .collect())
+}
+
+/// Write `signers` to [`AUTH_REF`], replacing any existing set, as a new commit.
+pub fn store(repo: &Path, signers: &[Signer]) -> Result<(), Error> {
+ let auth = Auth {
+ signers: signers
+ .iter()
+ .map(|signer| (signer.fingerprint.clone(), signer.key.clone()))
+ .collect(),
+ };
+ let odb = open_odb(repo).ok_or(Error::Odb)?;
+ let tree = facet_git_tree::serialize_into(&auth, &odb)?;
+ let commit = commit_tree(repo, &tree)?;
+ update_ref(repo, &commit)
}
/// Render `signers` as an OpenSSH `allowed_signers` file that authorizes any
@@ -58,32 +99,93 @@
.collect()
}
-/// Parse one `git ls-tree` line (`<mode> SP <type> SP <oid> TAB <name>`),
-/// yielding `(oid, name)` only for blob entries so nested trees are skipped.
-fn parse_blob_entry(line: &str) -> Option<(&str, &str)> {
- let (meta, name) = line.split_once('\t')?;
- let mut columns = meta.split_whitespace();
- let _mode = columns.next()?;
- if columns.next()? != "blob" {
- return None;
- }
- let oid = columns.next()?;
- Some((oid, name))
-}
-
-/// Run `git -C <repo> <args>` and return its stdout as a string, or `None` when
-/// git fails or the output is not UTF-8.
-fn git(repo: &Path, args: &[&str]) -> Option<String> {
+/// Resolve [`AUTH_REF`] to the object id of its tree, or `None` when the ref is
+/// absent.
+fn auth_tree(repo: &Path) -> Option<ObjectId> {
+ let spec = format!("{AUTH_REF}^{{tree}}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
- .args(args)
+ .args(["rev-parse", "--verify", "--quiet", &spec])
.output()
.ok()?;
if !output.status.success() {
return None;
}
- String::from_utf8(output.stdout).ok()
+ let hex = String::from_utf8(output.stdout).ok()?;
+ ObjectId::from_hex(hex.trim().as_bytes()).ok()
+}
+
+/// Open the repository's durable object database as a `gix` `Find`/`Write`
+/// backend.
+///
+/// Resolves the *common* git directory rather than `--git-path objects`: inside
+/// a `pre-receive` hook git points the latter at a quarantine holding only the
+/// incoming pack, while the current signer set lives in the durable store — and
+/// authorization is against the pre-push set, never the keys being pushed.
+fn open_odb(repo: &Path) -> Option<gix_odb::Handle> {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(["rev-parse", "--git-common-dir"])
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ let git_dir = String::from_utf8(output.stdout).ok()?;
+ gix_odb::at(repo.join(git_dir.trim()).join("objects")).ok()
+}
+
+/// Wrap `tree` in a commit, returning its object id. A fixed identity keeps the
+/// write self-contained, independent of any ambient git config.
+fn commit_tree(repo: &Path, tree: &ObjectId) -> Result<String, Error> {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args([
+ "commit-tree",
+ &tree.to_string(),
+ "-m",
+ "Update authorized signers",
+ ])
+ .env("GIT_AUTHOR_NAME", "git-ents")
+ .env("GIT_AUTHOR_EMAIL", "git-ents@localhost")
+ .env("GIT_COMMITTER_NAME", "git-ents")
+ .env("GIT_COMMITTER_EMAIL", "git-ents@localhost")
+ .output()
+ .map_err(|_source| Error::Git {
+ operation: "commit-tree",
+ })?;
+ if !output.status.success() {
+ return Err(Error::Git {
+ operation: "commit-tree",
+ });
+ }
+ String::from_utf8(output.stdout)
+ .map(|stdout| stdout.trim().to_owned())
+ .map_err(|_invalid| Error::Git {
+ operation: "commit-tree",
+ })
+}
+
+/// Point [`AUTH_REF`] at `commit`.
+fn update_ref(repo: &Path, commit: &str) -> Result<(), Error> {
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(["update-ref", AUTH_REF, commit])
+ .status()
+ .map_err(|_source| Error::Git {
+ operation: "update-ref",
+ })?;
+ if status.success() {
+ Ok(())
+ } else {
+ Err(Error::Git {
+ operation: "update-ref",
+ })
+ }
}
#[cfg(test)]
@@ -106,83 +208,60 @@
const KEY_B: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbB bob";
- fn unique_dir() -> PathBuf {
+ fn unique_repo() -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
- std::env::temp_dir().join(format!("git-ents-signers-{}-{n}", std::process::id()))
- }
-
- fn run(dir: &Path, args: &[&str]) {
+ let dir = std::env::temp_dir().join(format!("git-ents-signers-{}-{n}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
let status = Command::new("git")
.arg("-C")
- .arg(dir)
- .args(args)
- .env("GIT_AUTHOR_NAME", "t")
- .env("GIT_AUTHOR_EMAIL", "t@e")
- .env("GIT_COMMITTER_NAME", "t")
- .env("GIT_COMMITTER_EMAIL", "t@e")
+ .arg(&dir)
+ .args(["init", "-q"])
.status()
.unwrap();
- assert!(status.success(), "git {args:?} failed");
- }
-
- /// Build a repo whose `refs/meta/auth` carries `signers/<name>` blobs.
- fn repo_with_signers(entries: &[(&str, &str)]) -> PathBuf {
- let dir = unique_dir();
- std::fs::create_dir_all(dir.join("signers")).unwrap();
- run(&dir, &["init", "-q"]);
- for (name, key) in entries {
- std::fs::write(dir.join("signers").join(name), format!("{key}\n")).unwrap();
- }
- run(&dir, &["add", "signers"]);
- let tree = capture(&dir, &["write-tree"]);
- let commit = capture(&dir, &["commit-tree", &tree, "-m", "auth"]);
- run(&dir, &["update-ref", AUTH_REF, &commit]);
+ assert!(status.success());
dir
}
- fn capture(dir: &Path, args: &[&str]) -> String {
- git(dir, args).unwrap().trim().to_owned()
+ fn signer(fingerprint: &str, key: &str) -> Signer {
+ Signer {
+ fingerprint: fingerprint.to_owned(),
+ key: key.to_owned(),
+ }
}
#[test]
- fn loads_signers_from_the_auth_ref() {
- let dir = repo_with_signers(&[("SHA256-aaa", KEY_A), ("SHA256-bbb", KEY_B)]);
- let mut signers = load(&dir);
- signers.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
- assert_eq!(
- signers,
- vec![
- Signer {
- fingerprint: "SHA256-aaa".to_owned(),
- key: KEY_A.to_owned()
- },
- Signer {
- fingerprint: "SHA256-bbb".to_owned(),
- key: KEY_B.to_owned()
- },
- ]
- );
- let _ = std::fs::remove_dir_all(&dir);
+ fn store_then_load_round_trips_the_signer_set() {
+ let repo = unique_repo();
+ let written = vec![signer("SHA256-aaa", KEY_A), signer("SHA256-bbb", KEY_B)];
+ 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_auth_ref_is_absent() {
- let dir = unique_dir();
- std::fs::create_dir_all(&dir).unwrap();
- run(&dir, &["init", "-q"]);
- assert!(load(&dir).is_empty());
- let _ = std::fs::remove_dir_all(&dir);
+ let repo = unique_repo();
+ assert!(load(&repo).unwrap().is_empty());
+ let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn renders_a_wildcard_allowed_signers_file() {
- let signers = vec![Signer {
- fingerprint: "SHA256-aaa".to_owned(),
- key: KEY_A.to_owned(),
- }];
assert_eq!(
- allowed_signers(&signers),
+ allowed_signers(&[signer("SHA256-aaa", KEY_A)]),
format!("* namespaces=\"git\" {KEY_A}\n")
);
}