git-ents.gitmain
⌘K
foforge
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

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -54,6 +54,12 @@ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -365,6 +371,15 @@ "tree-sitter-language", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -564,6 +579,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -579,6 +600,15 @@ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -705,6 +735,108 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25929004897f2bbab309121a60400d36992f6d911d09baa6c172f6cc55706601" +[[package]] +name = "facet" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f200a48c8c3a5e394716595e12c6e886fdde8f08933ebf12161f559033ac34b5" +dependencies = [ + "autocfg", + "facet-core", + "facet-macros", + "facet-reflect", +] + +[[package]] +name = "facet-core" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6c4781319707e4c2c222d9d8ebd3f139f87b0592e3ee4ea959ae4f148064db" +dependencies = [ + "autocfg", + "const-fnv1a-hash", + "iddqd", + "impls", +] + +[[package]] +name = "facet-git-tree" +version = "0.1.0" +source = "git+https://github.com/git-ents/facet-git-tree#43f5298e489eefa3f6dd9238735e9975f533eb9c" +dependencies = [ + "facet", + "gix-hash", + "gix-object", + "gix-odb", + "thiserror", +] + +[[package]] +name = "facet-macro-parse" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc584827fce6ab10c1d472f99331c03249f9f9a2c284ffbc30f9e24c78f3cfa0" +dependencies = [ + "facet-macro-types", + "proc-macro2", + "quote", +] + +[[package]] +name = "facet-macro-types" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a6b438c050eb6498e36fb19291a9f4b6895f94d76e8c221bc123a1c8ea30940" +dependencies = [ + "proc-macro2", + "quote", + "unsynn", +] + +[[package]] +name = "facet-macros" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb7fd8ac402cd765f16332abd1c1a54b228cced0544f9df38febd75bc065842c" +dependencies = [ + "facet-macros-impl", +] + +[[package]] +name = "facet-macros-impl" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b754436e7ffef7b11df7c4d6a84ae41b75a15f20bc0833a0fdb211e1d78ad6" +dependencies = [ + "facet-macro-parse", + "facet-macro-types", + "proc-macro2", + "quote", + "strsim", + "unsynn", +] + +[[package]] +name = "facet-path" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9436ad11339cfb94fa4c858da32834b5a8a00d81aa593dfd1c356db6f0198873" +dependencies = [ + "facet-core", +] + +[[package]] +name = "facet-reflect" +version = "0.50.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0600129f210fbb325cfd8c1e6b4ef6eab5e5ab92e3b4a53d23c58a3cca39352" +dependencies = [ + "facet-core", + "facet-path", + "hashbrown 0.17.1", + "smallvec 2.0.0-alpha.12", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -727,6 +859,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -811,6 +949,12 @@ [[package]] name = "git-ents" version = "0.0.0" +dependencies = [ + "facet", + "facet-git-tree", + "gix-odb", + "thiserror", +] [[package]] name = "git-ents-server" @@ -845,6 +989,15 @@ "gix-error", ] +[[package]] +name = "gix-chunk" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +dependencies = [ + "gix-error", +] + [[package]] name = "gix-date" version = "0.15.4" @@ -872,9 +1025,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" dependencies = [ + "crc32fast", + "gix-path", "gix-trace", + "gix-utils", "libc", "prodash", + "thiserror", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", ] [[package]] @@ -896,15 +1069,15 @@ checksum = "b0e30b93eea8718baf7d8153fcb938e2926175bbf18097c09f1c01b6f0be0563" dependencies = [ "gix-hash", - "hashbrown", + "hashbrown 0.17.1", "parking_lot", ] [[package]] name = "gix-object" -version = "0.60.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a38075a95d7cc5df8afd38e72c617026c1456952207a4120a7f55a3fbf93b4d7" +checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525" dependencies = [ "bstr", "gix-actor", @@ -915,10 +1088,71 @@ "gix-utils", "gix-validate", "itoa", - "smallvec", + "smallvec 1.15.2", "thiserror", ] +[[package]] +name = "gix-odb" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.71.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174" +dependencies = [ + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-object", + "gix-path", + "memmap2", + "smallvec 1.15.2", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + [[package]] name = "gix-trace" version = "0.1.20" @@ -959,11 +1193,25 @@ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heapless" @@ -1042,7 +1290,7 @@ "httpdate", "itoa", "pin-project-lite", - "smallvec", + "smallvec 1.15.2", "tokio", ] @@ -1122,7 +1370,7 @@ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec", + "smallvec 1.15.2", "zerovec", ] @@ -1167,6 +1415,18 @@ "zerovec", ] +[[package]] +name = "iddqd" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cf7e72dd082126bd727c1f8bd2d5229c2780258c0b3c7bc33eaf0f4ea5f0fa" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "hashbrown 0.16.1", +] + [[package]] name = "idna" version = "1.1.0" @@ -1174,7 +1434,7 @@ checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec", + "smallvec 1.15.2", "utf8_iter", ] @@ -1188,6 +1448,12 @@ "icu_properties", ] +[[package]] +name = "impls" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc" + [[package]] name = "indexmap" version = "2.14.0" @@ -1195,7 +1461,7 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1332,6 +1598,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -1349,6 +1624,12 @@ "windows-sys", ] +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + [[package]] name = "num-traits" version = "0.2.19" @@ -1389,7 +1670,7 @@ "cfg-if", "libc", "redox_syscall", - "smallvec", + "smallvec 1.15.2", "windows-link", ] @@ -1651,6 +1932,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1784,6 +2074,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smallvec" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" + [[package]] name = "socket2" version = "0.6.4" @@ -2042,6 +2338,17 @@ "tinyvec", ] +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash", +] + [[package]] name = "url" version = "2.5.8" @@ -2072,6 +2379,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2123,6 +2440,15 @@ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2283,6 +2609,12 @@ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + [[package]] name = "zmij" version = "1.0.21"
Cargo.toml @@ -4,7 +4,7 @@ [workspace.package] edition = "2024" -publish = true +publish = false license = "MIT OR Apache-2.0" [workspace.lints.rust] @@ -35,9 +35,12 @@ axum = "0.8" clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" +facet = { version = "0.50.0-rc.0", features = ["reflect"] } +facet-git-tree = { git = "https://github.com/git-ents/facet-git-tree" } gix = { version = "0.83", features = ["no-default-features"] } gix-hash = { version = "0.25", features = ["sha1"] } -gix-object = "0.60" +gix-object = "0.61" +gix-odb = "0.81" gix-actor = "0.41" gix-date = "0.15" maud = { version = "0.27", features = ["axum"] }
crates/git-ents/Cargo.toml @@ -5,5 +5,11 @@ publish.workspace = true license.workspace = true +[dependencies] +facet = { workspace = true } +facet-git-tree = { workspace = true } +gix-odb = { workspace = true } +thiserror = { workspace = true } + [lints] workspace = true
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") ); }