git-ents.gitmain
⌘K
foforge
commit 8ceb2de
refactor: extract git-member crate for the Signed push data model

Meta-ref + Typed tree already lived cleanly in git-store; this phase gives the Signed push abstraction the same treatment, pulling members, revocations, and the config ref_allowed/glob_match authorization rule out of git-ents-core into a new git-member crate. The generic Document/MapDocument/Collection/Component storage-layout traits move from git-ents-core into git-store itself, since both git-member and the shrunken git-ents-core need them without a dependency cycle.

feat: add git-member crate (members, revocations, ref_allowed/glob_match) refactor: move component.rs storage-layout traits from git-ents-core to git-store refactor: rewire git-ents, git-ents-server, and git-ents-core to the new crate boundaries docs: point conformance.adoc at the moved members/revocations/config file paths Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -1471,6 +1471,7 @@ "git-comment", "git-ents-core", "git-ents-server", + "git-member", "git-store", "git-toolchain", "inquire", @@ -1488,7 +1489,6 @@ "facet", "git-store", "gix", - "iddqd", "thiserror 2.0.18", ] @@ -1510,6 +1510,7 @@ "git-anchor", "git-comment", "git-ents-core", + "git-member", "git-store", "git-toolchain", "gix-actor", @@ -1525,6 +1526,18 @@ "uuid", ] +[[package]] +name = "git-member" +version = "0.0.0" +dependencies = [ + "facet", + "git-ents-core", + "git-store", + "gix", + "iddqd", + "thiserror 2.0.18", +] + [[package]] name = "git-store" version = "0.0.0"
Cargo.toml @@ -6,6 +6,7 @@ "crates/git-ents", "crates/git-ents-core", "crates/git-ents-server", + "crates/git-member", "crates/git-store", "crates/git-toolchain", ] @@ -55,6 +56,7 @@ git-comment = { path = "crates/git-comment" } git-ents-core = { path = "crates/git-ents-core" } git-ents-server = { path = "crates/git-ents-server" } +git-member = { path = "crates/git-member" } git-store = { path = "crates/git-store" } git-toolchain = { path = "crates/git-toolchain" } gix = "0.84"
crates/git-ents-core/Cargo.toml @@ -9,7 +9,6 @@ facet = { workspace = true } git-store = { workspace = true } gix = { workspace = true } -iddqd = { workspace = true } thiserror = { workspace = true } [dev-dependencies]
crates/git-ents-server/Cargo.toml @@ -20,6 +20,7 @@ getrandom = { workspace = true } git-anchor = { workspace = true } git-comment = { workspace = true } +git-member = { workspace = true } git-store = { workspace = true } git-toolchain = { workspace = true } gix-actor = { workspace = true }
crates/git-ents/Cargo.toml @@ -16,6 +16,7 @@ git-comment = { workspace = true } git-ents-core = { workspace = true } git-ents-server = { workspace = true } +git-member = { workspace = true } git-store = { workspace = true } git-toolchain = { workspace = true } inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] }
docs/spec/conformance.adoc @@ -19,14 +19,14 @@ |`namespace.url` |`README.adoc`/deployment config | _(documentation only)_ |`namespace.path` |`git-ents-server/src/http.rs` (segment validation) |`http::tests::validates_segments`, `extracts_repo_path` |`namespace.auto-create` |`git-ents-server/src/http.rs` (`backend` init lock, `reconcile_head`) |`server::rejects_colliding_pushes` -|`members.ref` |`git-ents/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref` -|`members.trust` |`git-ents/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}` -|`members.provenance` |`git-ents/src/members.rs` (`Provenance`); `git-ents-server/src/web/write.rs` (`require_admin_registered`) |`members::loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered`, `web_edit::a_self_attested_member_is_refused_a_settings_edit` -|`members.account` |`git-ents/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member` -|`members.window` |`git-ents/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window` -|`members.allowed-signers` |`git-ents/src/members.rs` (`allowed_signers`, `member_lines`) |`members::renders_a_wildcard_allowed_signers_file`, `renders_the_validity_window_as_comma_joined_options` -|`revocations.ref` |`git-ents/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations` -|`revocations.ca` |`git-ents/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched` +|`members.ref` |`git-member/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref` +|`members.trust` |`git-member/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}` +|`members.provenance` |`git-member/src/members.rs` (`Provenance`); `git-ents-server/src/web/write.rs` (`require_admin_registered`) |`members::loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered`, `web_edit::a_self_attested_member_is_refused_a_settings_edit` +|`members.account` |`git-member/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member` +|`members.window` |`git-member/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window` +|`members.allowed-signers` |`git-member/src/members.rs` (`allowed_signers`, `member_lines`) |`members::renders_a_wildcard_allowed_signers_file`, `renders_the_validity_window_as_comma_joined_options` +|`revocations.ref` |`git-member/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations` +|`revocations.ca` |`git-member/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched` |`auth.bootstrap` |`git-ents-server/src/verify.rs` (`pre_receive`, empty-members early return) |`pre_receive` integration tests (bootstrap case) |`auth.signed-push` |`git-ents-server/src/verify.rs` (`verify_certificate`) |`pre_receive::rejects_an_unsigned_push_when_signers_exist`, `rejects_a_push_signed_by_an_unknown_key` |`auth.nonce` |`git-ents-server/src/http.rs` (`backend_config_injects_nonce_seed_and_hooks_path`) |`http::tests::backend_config_injects_nonce_seed_and_hooks_path` @@ -41,7 +41,7 @@ |`cli.key-resolution` |`git-ents/src/main.rs` (`fingerprint`, MD5 colon form) | _(manual/CLI UX, no automated test)_ |`account.ref` |`git-ents/src/account.rs` |`account::store_then_load_round_trips_the_account`, `the_account_ref_marks_an_account_repo` |`account.genesis` |`git-ents/src/account.rs` (`genesis`) | _(exercised via `members add --account`; no dedicated unit test)_ -|`config.ref` |`git-ents/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent` +|`config.ref` |`git-ents-core/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent` |`checks.definition` |`git-ents/src/checks.rs` (`load`/`store`, `CHECKS_REF`, `order`) |`checks::store_then_load_round_trips_the_check_set`, `store_then_load_round_trips_image_and_depends`, `order_runs_dependencies_first`, `order_rejects_a_cycle`, `order_rejects_an_unknown_dependency`, `order_rejects_self_and_duplicate_edges`, `order_rejects_an_empty_check` |`checks.post-receive` |`git-ents-server/src/checks.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_ |`checks.worker` |`git-ents-server/src/checks.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`checks::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-ents`)_
crates/git-ents-core/src/account.rs @@ -11,7 +11,7 @@ use facet::Facet; -use crate::component; +use git_store::component; // @relation(account.ref) /// The ref whose tree holds the account profile, and whose mere presence marks a
crates/git-ents-core/src/checks.rs @@ -26,7 +26,7 @@ use facet::Facet; use gix::ObjectId; -use crate::component; +use git_store::component; /// The ref whose tree holds the configured check set. pub const CHECKS_REF: &str = "refs/meta/checks";
crates/git-ents-core/src/config.rs @@ -13,7 +13,7 @@ use facet::Facet; -use crate::component; +use git_store::component; // @relation(config.ref) /// The ref whose tree holds the repository configuration. @@ -30,10 +30,10 @@ /// The repository's topics, members-gated metadata rather than worktree /// content. pub topics: Vec<String>, - /// Ref-push rules keyed by role name, matched against a pushing - /// [`crate::members::Member`]'s `role`. A role absent here — or a member - /// with no role at all — permits every ref: role rules are opt-in gating - /// layered on top of that default-allow-all rule. + /// Ref-push rules keyed by role name, matched against a pushing member's + /// `role` (see `git_member::ref_allowed`). A role absent here — or a + /// member with no role at all — permits every ref: role rules are opt-in + /// gating layered on top of that default-allow-all rule. pub roles: BTreeMap<String, RoleRules>, } @@ -57,54 +57,6 @@ pub deny: Vec<String>, } -/// Whether `role`'s rules in `config` permit pushing to `ref_name`. `role` -/// being `None`, or naming a role absent from `config.roles`, permits every -/// ref — see [`Config::roles`]. -#[must_use] -pub fn ref_allowed(config: &Config, role: Option<&str>, ref_name: &str) -> bool { - let Some(role) = role else { - return true; - }; - let Some(rules) = config.roles.get(role) else { - return true; - }; - if rules - .deny - .iter() - .any(|pattern| glob_match(pattern, ref_name)) - { - return false; - } - rules.allow.is_empty() - || rules - .allow - .iter() - .any(|pattern| glob_match(pattern, ref_name)) -} - -/// Whether `text` matches `pattern`, where `*` in `pattern` matches any run of -/// characters (including none, and including `/`). -#[must_use] -pub fn glob_match(pattern: &str, text: &str) -> bool { - fn go(pattern: &[u8], text: &[u8]) -> bool { - match pattern.split_first() { - None => text.is_empty(), - Some((b'*', rest)) => { - go(rest, text) - || match text.split_first() { - Some((_, t_rest)) => go(pattern, t_rest), - None => false, - } - } - Some((c, rest)) => match text.split_first() { - Some((t, t_rest)) if t == c => go(rest, t_rest), - _ => false, - }, - } - } - go(pattern.as_bytes(), text.as_bytes()) -} - /// Load the configuration recorded at [`CONFIG_REF`] from an already-open /// `store`. /// @@ -208,53 +160,4 @@ assert_eq!(load(&repo).unwrap(), Config::default()); let _ = std::fs::remove_dir_all(&repo); } - - #[test] - fn glob_match_supports_a_trailing_star() { - assert!(glob_match("refs/heads/*", "refs/heads/main")); - assert!(glob_match("refs/heads/*", "refs/heads/")); - assert!(!glob_match("refs/heads/*", "refs/tags/v1")); - assert!(glob_match("*", "anything")); - } - - #[test] - fn ref_allowed_defaults_to_true_with_no_role_or_unlisted_role() { - let mut config = Config::default(); - config.roles.insert( - "readonly".to_owned(), - RoleRules { - allow: vec![], - deny: vec!["refs/heads/*".to_owned()], - }, - ); - assert!(ref_allowed(&config, None, "refs/heads/main")); - assert!(ref_allowed(&config, Some("nonexistent"), "refs/heads/main")); - } - - #[test] - fn ref_allowed_checks_deny_before_allow() { - let mut config = Config::default(); - config.roles.insert( - "release-manager".to_owned(), - RoleRules { - allow: vec!["refs/heads/release-*".to_owned()], - deny: vec!["refs/heads/release-locked".to_owned()], - }, - ); - assert!(ref_allowed( - &config, - Some("release-manager"), - "refs/heads/release-1.0" - )); - assert!(!ref_allowed( - &config, - Some("release-manager"), - "refs/heads/release-locked" - )); - assert!(!ref_allowed( - &config, - Some("release-manager"), - "refs/heads/main" - )); - } }
crates/git-ents-core/src/issues.rs @@ -29,7 +29,7 @@ use facet::Facet; -use crate::component; +use git_store::component; // @relation(issues.ref) /// The namespace under which issues are recorded: one ref,
crates/git-ents-core/src/lib.rs @@ -3,11 +3,8 @@ pub mod account; pub mod checks; -pub mod component; pub mod config; pub mod issues; -pub mod members; -pub mod revocations; #[cfg(test)] mod testutil;
crates/git-ents-core/src/testutil.rs @@ -44,120 +44,6 @@ dir } -/// 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, - 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"], ""), - Some(value) => { - let blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value); - git_with_stdin(repo, &["mktree"], &format!("100644 blob {blob}\tsome\n")) - } - }; - 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); - key_entries.push_str(&format!("100644 blob {key_blob}\t{fingerprint}\n")); - } - 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!( - "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 a `Member` document out at `refs/meta/member/<username>` with -/// `Trust::WebAuthn` credentials and an explicit `provenance`, as the real -/// on-disk format: a `trust/WebAuthn/<credential_id>/{cose_key,label}` -/// subtree per credential (the `Trust::WebAuthn` newtype variant resolving -/// directly to its map, each `WebAuthnKey` a two-field subtree) and a -/// `provenance/<variant>` unit-variant tree. Asserts the loader still reads -/// the format independent of the writer. -pub(crate) fn write_webauthn_member_doc( - repo: &Path, - username: &str, - provenance: &str, - credentials: &[(&str, &str, &str)], -) { - let empty_tree = git_with_stdin(repo, &["mktree"], ""); - let principal_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], username); - let mut cred_entries = String::new(); - for (credential_id, cose_key, label) in credentials { - let case_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], cose_key); - let label_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], label); - let cred_tree = git_with_stdin( - repo, - &["mktree"], - &format!("100644 blob {case_blob}\tcose_key\n100644 blob {label_blob}\tlabel\n"), - ); - cred_entries.push_str(&format!("040000 tree {cred_tree}\t{credential_id}\n")); - } - let creds_tree = git_with_stdin(repo, &["mktree"], &cred_entries); - let trust_tree = git_with_stdin( - repo, - &["mktree"], - &format!("040000 tree {creds_tree}\tWebAuthn\n"), - ); - let provenance_tree = git_with_stdin( - repo, - &["mktree"], - &format!("040000 tree {empty_tree}\t{provenance}\n"), - ); - let root = git_with_stdin( - repo, - &["mktree"], - &format!( - "100644 blob {principal_blob}\tprincipal\n\ - 040000 tree {empty_tree}\tvalid_after\n\ - 040000 tree {empty_tree}\tvalid_before\n\ - 040000 tree {trust_tree}\ttrust\n\ - 040000 tree {provenance_tree}\tprovenance\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 @@ -352,34 +238,6 @@ assert!(status.success()); } -/// Lay a `Revocations` document out at -/// [`crate::revocations::REVOKED_REF`] as the real on-disk format after the -/// revocations migration, at the ref's tree root — a bare scalar-keyed map, -/// no wrapper struct: a `<fingerprint>/reason` blob per entry (the map value -/// is a `RevocationBody` subtree, not a bare blob). Asserts the loader still -/// reads the format independent of the writer. -pub(crate) fn write_revocations_doc(repo: &Path, revoked: &[(&str, &str)]) { - let mut entries = String::new(); - for (fingerprint, reason) in revoked { - let reason_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], reason); - let body_tree = git_with_stdin( - repo, - &["mktree"], - &format!("100644 blob {reason_blob}\treason\n"), - ); - entries.push_str(&format!("040000 tree {body_tree}\t{fingerprint}\n")); - } - let revoked_tree = git_with_stdin(repo, &["mktree"], &entries); - let commit = git_with_stdin(repo, &["commit-tree", &revoked_tree, "-m", "fixture"], ""); - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(["update-ref", crate::revocations::REVOKED_REF, &commit]) - .status() - .unwrap(); - assert!(status.success()); -} - /// Run git in `repo` with `input` on stdin, returning its trimmed stdout. fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String { git_store::test_support::git_with_stdin(repo, args, input)
crates/git-ents-server/src/verify.rs @@ -15,8 +15,8 @@ use std::process::{Command, Stdio}; use git_ents_core::config; -use git_ents_core::members::{self, Member}; -use git_ents_core::revocations; +use git_member::members::{self, Member}; +use git_member::revocations; /// 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 @@ -62,7 +62,7 @@ let config = config::load_with(&store).map_err(|e| format!("could not read configuration: {e}"))?; for ref_name in &ref_updates { - if !config::ref_allowed(&config, member.role.as_deref(), ref_name) { + if !git_member::ref_allowed(&config, member.role.as_deref(), ref_name) { return Err(format!( "{} (role {:?}) is not permitted to push to {ref_name:?}", member.principal, member.role
crates/git-ents/src/main.rs @@ -8,7 +8,7 @@ //! code comments at `refs/meta/comments/<id>`, 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_core::members`], and +//! local repository, editing them through [`git_member::members`], and //! pushing them back. mod debug_session; @@ -26,9 +26,9 @@ use git_comment::{COMMENTS_NS, Comment}; use git_ents_core::account::{self, Account}; use git_ents_core::checks::{self, CHECKS_REF, Check}; -use git_ents_core::component::{self, Component, MapDocument}; -use git_ents_core::members::{self, MEMBER_NS, Member, Trust, member_ref}; -use git_ents_core::revocations::{self, REVOKED_REF, Revocation}; +use git_member::members::{self, MEMBER_NS, Member, Trust, member_ref}; +use git_member::revocations::{self, REVOKED_REF, Revocation}; +use git_store::component::{self, Component, MapDocument}; use git_toolchain::TOOLCHAINS_NS; /// Helpful guardians of your git trees.
crates/git-store/src/lib.rs @@ -28,6 +28,7 @@ use gix::refs::Target; use gix::refs::transaction::PreviousValue; +pub mod component; mod merge; /// The author and committer identity stamped on every write, fixed so a write
crates/git-ents-server/src/web/component.rs @@ -15,7 +15,7 @@ use std::path::Path; -use git_ents_core::component::Component; +use git_store::component::Component; use maud::{Markup, html}; use super::render::Render; @@ -38,7 +38,7 @@ } /// A [`Loadable`] component whose items also render as a generic [`card`]: -/// identity metadata and a [`Render`] impl (both from `git_ents_core::component`), +/// identity metadata and a [`Render`] impl (both from `git_store::component`), /// plus a title and what the card shows when there are no items yet. pub(super) trait WebComponent: Loadable + Component + Render { /// The card title.
crates/git-ents-server/src/web/pages.rs @@ -1214,7 +1214,7 @@ auth: Option<&super::Auth>, editing: bool, ) -> Markup { - let members = component::load::<git_ents_core::members::Member>(repo).await; + let members = component::load::<git_member::members::Member>(repo).await; let checks = component::load::<git_ents_core::checks::Check>(repo).await; let config = load_repo_config(repo).await; repo_shell(
crates/git-ents-server/src/web/render.rs @@ -16,7 +16,7 @@ use git_ents_core::checks::{Check, Run, RunOutcome, Status}; use git_ents_core::config::{Config, RoleRules}; use git_ents_core::issues::Issue; -use git_ents_core::members::Member; +use git_member::members::Member; use super::component::{Loadable, WebComponent}; use crate::asciidoc; @@ -145,7 +145,7 @@ impl Loadable for Member { fn load(repo: &Path) -> Result<Vec<Self>, String> { - git_ents_core::members::load_all(repo).map_err(|err| err.to_string()) + git_member::members::load_all(repo).map_err(|err| err.to_string()) } }
crates/git-ents-server/src/web/write.rs @@ -213,8 +213,8 @@ /// /// @relation(web.auth.edit) fn require_admin_registered(store: &git_store::Store, username: &str) -> Result<(), String> { - use git_ents_core::members::Provenance; - let member = git_ents_core::members::load_with(store, username) + use git_member::members::Provenance; + let member = git_member::members::load_with(store, username) .map_err(|e| format!("could not read member: {e}"))? .ok_or_else(|| "your web key is not a member of this repository".to_owned())?; match member.provenance { @@ -512,7 +512,7 @@ public_key: &str, ) -> Option<String> { let wanted = normalize_key(public_key); - let members = git_ents_core::members::load_all_with(store).ok()?; + let members = git_member::members::load_all_with(store).ok()?; members.into_iter().find_map(|member| { member .keys()
crates/git-ents-core/src/component.rs → crates/git-store/src/component.rs @@ -5,36 +5,36 @@ //! document on one ref), [`MapDocument`] (named entries in one scalar-keyed //! map on one ref), or [`Collection`] (one ref per item under a namespace) — //! and the free functions here are the single place that turns each trait -//! into the matching [`git_store::Store`] call, so a module's own +//! into the matching [`crate::Store`] call, so a module's own //! `load`/`store` shrinks to a one-line delegation instead of hand-formatting //! a ref name. use facet::Facet; -/// A type stored whole on a single meta ref (e.g. [`crate::config::Config`], -/// [`crate::account::Account`]). +/// A type stored whole on a single meta ref (e.g. a repository's +/// configuration or account profile). pub trait Document: for<'a> Facet<'a> { /// The ref the document lives on. const REF: &'static str; } /// Load the document at [`Document::REF`], or `None` when the ref is absent. -pub fn load<T: Document>(store: &git_store::Store) -> Result<Option<T>, git_store::Error> { +pub fn load<T: Document>(store: &crate::Store) -> Result<Option<T>, crate::Error> { store.load(T::REF) } /// Write `value` to [`Document::REF`], replacing any existing value as a new /// commit. pub fn store<T: Document>( - store: &git_store::Store, + store: &crate::Store, value: &T, message: &str, -) -> Result<(), git_store::Error> { +) -> Result<(), crate::Error> { store.store(T::REF, value, message) } -/// A type stored as one `<key> -> body` map document on a single ref (e.g. -/// [`crate::checks::Check`], [`crate::revocations::Revocation`]). +/// A type stored as one `<key> -> body` map document on a single ref (e.g. a +/// configured check set or a revocation list). pub trait MapDocument: Sized { /// The ref the map document lives on. const REF: &'static str; @@ -48,16 +48,16 @@ /// Load [`MapDocument::REF`]'s entries as their flattened item list. An /// absent ref yields an empty list. -pub fn load_map<T: MapDocument>(store: &git_store::Store) -> Result<Vec<T>, git_store::Error> { +pub fn load_map<T: MapDocument>(store: &crate::Store) -> Result<Vec<T>, crate::Error> { store.load_map(T::REF, T::compose) } /// Replace [`MapDocument::REF`]'s entries with `items`. pub fn store_map<T: MapDocument>( - store: &git_store::Store, + store: &crate::Store, items: &[T], message: &str, -) -> Result<(), git_store::Error> { +) -> Result<(), crate::Error> { store.store_map( T::REF, items, @@ -69,13 +69,12 @@ ) } -/// A type stored decomposed, one ref per item, under a namespace (e.g. -/// [`crate::members::Member`], [`crate::issues::Issue`]). Deliberately not -/// bound on [`git_store::HasId`]: an issue's ref key is its genesis hash, a -/// value never stored inside the document itself, so [`load_item`]/ -/// [`store_item`] take the id explicitly; [`store_keyed`] is the add-on for a -/// collection (like [`crate::members::Member`]) whose item legitimately -/// carries its own key. +/// A type stored decomposed, one ref per item, under a namespace (e.g. a +/// member or an issue). Deliberately not bound on [`crate::HasId`]: an +/// issue's ref key is its genesis hash, a value never stored inside the +/// document itself, so [`load_item`]/[`store_item`] take the id explicitly; +/// [`store_keyed`] is the add-on for a collection (like a member) whose item +/// legitimately carries its own key. pub trait Collection: for<'a> Facet<'a> { /// The ref namespace (`{NS}/{id}` per item) its items live under. const NS: &'static str; @@ -83,36 +82,33 @@ /// Load the item `id` under [`Collection::NS`], or `None` when its ref is /// absent. -pub fn load_item<T: Collection>( - store: &git_store::Store, - id: &str, -) -> Result<Option<T>, git_store::Error> { +pub fn load_item<T: Collection>(store: &crate::Store, id: &str) -> Result<Option<T>, crate::Error> { store.load_item(T::NS, id) } /// Store `value` as item `id` under [`Collection::NS`]. pub fn store_item<T: Collection>( - store: &git_store::Store, + store: &crate::Store, id: &str, value: &T, message: &str, -) -> Result<(), git_store::Error> { +) -> Result<(), crate::Error> { store.store_item(T::NS, id, value, message) } -/// Store `value` as item [`git_store::HasId::id`] under [`Collection::NS`], +/// Store `value` as item [`crate::HasId::id`] under [`Collection::NS`], /// for a collection whose item carries its own key. -pub fn store_keyed<T: Collection + git_store::HasId>( - store: &git_store::Store, +pub fn store_keyed<T: Collection + crate::HasId>( + store: &crate::Store, value: &T, message: &str, -) -> Result<(), git_store::Error> { +) -> Result<(), crate::Error> { store.store_keyed(T::NS, value, message) } /// Every item under [`Collection::NS`], paired with the id its ref was stored /// under, newest first. -pub fn list<T: Collection>(store: &git_store::Store) -> Result<Vec<(String, T)>, git_store::Error> { +pub fn list<T: Collection>(store: &crate::Store) -> Result<Vec<(String, T)>, crate::Error> { store.list_items(T::NS) }
crates/git-ents-core/tests/cert_authority.rs → crates/git-member/tests/cert_authority.rs @@ -7,7 +7,7 @@ )] //! The Phase 3 CA-pin gate: a certificate the pinned CA issued verifies against -//! the `allowed_signers` file `git_ents_core::members` renders, and a certificate +//! the `allowed_signers` file `git_member::members` renders, and a certificate //! from an unpinned CA does not. The cert-embedded signature is produced the way //! a real client would — through an `ssh-agent` holding the key and its //! certificate — since `ssh-keygen -Y sign` only embeds a certificate when the @@ -17,7 +17,7 @@ use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; -use git_ents_core::members::{Member, allowed_signers}; +use git_member::members::{Member, allowed_signers}; /// The principal the CA certifies and the verifier checks — the pusher identity. const PRINCIPAL: &str = "tester@example.com"; @@ -61,7 +61,7 @@ std::fs::remove_dir_all(&dir).ok(); } -/// Write the `allowed_signers` file `git_ents_core::members` renders for a member +/// Write the `allowed_signers` file `git_member::members` renders for a member /// whose trust is the CA `ca`, and return its path. fn render_ca_allowed_signers(dir: &Path, name: &str, ca: &Key) -> PathBuf { let ca_pubkey = std::fs::read_to_string(&ca.public)
crates/git-ents-core/src/members.rs → crates/git-member/src/members.rs @@ -38,7 +38,7 @@ use facet::Facet; -use crate::component; +use git_store::component; // @relation(members.ref) /// The namespace whose refs hold the member set — the push trust root. One @@ -78,13 +78,13 @@ #[facet(default)] pub provenance: Provenance, /// The `@`-mentioned account this member is, by its stable - /// [`crate::account::genesis`] hash — `None` until an admin links one. + /// [`git_ents_core::account::genesis`] hash — `None` until an admin links one. /// Plain `Option`, which `facet-git-tree` auto-defaults on an absent /// entry, so a member ref written before this field existed keeps /// loading unchanged. pub account: Option<String>, /// The member's role, matched against `refs/meta/config`'s - /// [`crate::config::Config::roles`] to gate which refs they may push to. + /// [`git_ents_core::config::Config::roles`] to gate which refs they may push to. /// `None` (or a role absent from that map) permits every ref — role /// rules are opt-in gating layered on top of the default-allow-all rule. /// Plain `Option`, so a member ref written before this field existed
crates/git-ents-core/src/revocations.rs → crates/git-member/src/revocations.rs @@ -28,7 +28,7 @@ use facet::Facet; -use crate::component; +use git_store::component; // @relation(revocations.ref) /// The ref whose tree holds the revocation list — the deny overlay on the trust
crates/git-member/Cargo.toml @@ -1,0 +1,20 @@ +[package] +name = "git-member" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } +git-ents-core = { workspace = true } +git-store = { workspace = true } +gix = { workspace = true } +iddqd = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +git-store = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true
crates/git-member/src/lib.rs @@ -1,0 +1,14 @@ +//! The Signed push abstraction's data model: who is trusted to push +//! (`members`), who has been struck from that trust (`revocations`), and +//! which refs a trusted member's role permits (`policy`). +//! +//! Verifying a push certificate against this trust set is a separate concern +//! — see `git-signed-push`. + +pub mod members; +pub mod policy; +pub mod revocations; +#[cfg(test)] +mod testutil; + +pub use policy::{glob_match, ref_allowed};
crates/git-member/src/policy.rs @@ -1,0 +1,113 @@ +//! Ref-push authorization: whether a member's role permits a push to a given +//! ref, per the rules in `refs/meta/config`. + +use git_ents_core::config::Config; + +/// Whether `role`'s rules in `config` permit pushing to `ref_name`. `role` +/// being `None`, or naming a role absent from `config.roles`, permits every +/// ref — see [`Config::roles`]. +#[must_use] +pub fn ref_allowed(config: &Config, role: Option<&str>, ref_name: &str) -> bool { + let Some(role) = role else { + return true; + }; + let Some(rules) = config.roles.get(role) else { + return true; + }; + if rules + .deny + .iter() + .any(|pattern| glob_match(pattern, ref_name)) + { + return false; + } + rules.allow.is_empty() + || rules + .allow + .iter() + .any(|pattern| glob_match(pattern, ref_name)) +} + +/// Whether `text` matches `pattern`, where `*` in `pattern` matches any run of +/// characters (including none, and including `/`). +#[must_use] +pub fn glob_match(pattern: &str, text: &str) -> bool { + fn go(pattern: &[u8], text: &[u8]) -> bool { + match pattern.split_first() { + None => text.is_empty(), + Some((b'*', rest)) => { + go(rest, text) + || match text.split_first() { + Some((_, t_rest)) => go(pattern, t_rest), + None => false, + } + } + Some((c, rest)) => match text.split_first() { + Some((t, t_rest)) if t == c => go(rest, t_rest), + _ => false, + }, + } + } + go(pattern.as_bytes(), text.as_bytes()) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use super::*; + use git_ents_core::config::RoleRules; + + #[test] + fn glob_match_supports_a_trailing_star() { + assert!(glob_match("refs/heads/*", "refs/heads/main")); + assert!(glob_match("refs/heads/*", "refs/heads/")); + assert!(!glob_match("refs/heads/*", "refs/tags/v1")); + assert!(glob_match("*", "anything")); + } + + #[test] + fn ref_allowed_defaults_to_true_with_no_role_or_unlisted_role() { + let mut config = Config::default(); + config.roles.insert( + "readonly".to_owned(), + RoleRules { + allow: vec![], + deny: vec!["refs/heads/*".to_owned()], + }, + ); + assert!(ref_allowed(&config, None, "refs/heads/main")); + assert!(ref_allowed(&config, Some("nonexistent"), "refs/heads/main")); + } + + #[test] + fn ref_allowed_checks_deny_before_allow() { + let mut config = Config::default(); + config.roles.insert( + "release-manager".to_owned(), + RoleRules { + allow: vec!["refs/heads/release-*".to_owned()], + deny: vec!["refs/heads/release-locked".to_owned()], + }, + ); + assert!(ref_allowed( + &config, + Some("release-manager"), + "refs/heads/release-1.0" + )); + assert!(!ref_allowed( + &config, + Some("release-manager"), + "refs/heads/release-locked" + )); + assert!(!ref_allowed( + &config, + Some("release-manager"), + "refs/heads/main" + )); + } +}
crates/git-member/src/testutil.rs @@ -1,0 +1,192 @@ +//! Shared test helpers for the member and revocation modules: a throwaway git +//! repository and a builder that lays an on-disk `refs/meta/*` document out +//! with raw git plumbing. +//! +//! Building the tree directly — rather than through [`git_store::Store`] — pins +//! the *on-disk* layout each document type promises: a `<subtree>/<key>` blob +//! per entry. A load test against a fixture written this way fails the moment an +//! incompatible change to a document's [`facet::Facet`] shape stops reading data +//! already in the wild, the failure mode that broke every push once before. + +#![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "test support" +)] + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// A freshly initialized, uniquely named git repository under the temp dir. +#[must_use] +pub(crate) fn unique_repo(label: &str) -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("git-member-{label}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let status = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["init", "-q"]) + .status() + .unwrap(); + assert!(status.success()); + for (key, value) in [("user.email", "test@example.com"), ("user.name", "Test")] { + let status = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["config", key, value]) + .status() + .unwrap(); + assert!(status.success()); + } + dir +} + +/// 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, + 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"], ""), + Some(value) => { + let blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value); + git_with_stdin(repo, &["mktree"], &format!("100644 blob {blob}\tsome\n")) + } + }; + 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); + key_entries.push_str(&format!("100644 blob {key_blob}\t{fingerprint}\n")); + } + 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!( + "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 a `Member` document out at `refs/meta/member/<username>` with +/// `Trust::WebAuthn` credentials and an explicit `provenance`, as the real +/// on-disk format: a `trust/WebAuthn/<credential_id>/{cose_key,label}` +/// subtree per credential (the `Trust::WebAuthn` newtype variant resolving +/// directly to its map, each `WebAuthnKey` a two-field subtree) and a +/// `provenance/<variant>` unit-variant tree. Asserts the loader still reads +/// the format independent of the writer. +pub(crate) fn write_webauthn_member_doc( + repo: &Path, + username: &str, + provenance: &str, + credentials: &[(&str, &str, &str)], +) { + let empty_tree = git_with_stdin(repo, &["mktree"], ""); + let principal_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], username); + let mut cred_entries = String::new(); + for (credential_id, cose_key, label) in credentials { + let case_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], cose_key); + let label_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], label); + let cred_tree = git_with_stdin( + repo, + &["mktree"], + &format!("100644 blob {case_blob}\tcose_key\n100644 blob {label_blob}\tlabel\n"), + ); + cred_entries.push_str(&format!("040000 tree {cred_tree}\t{credential_id}\n")); + } + let creds_tree = git_with_stdin(repo, &["mktree"], &cred_entries); + let trust_tree = git_with_stdin( + repo, + &["mktree"], + &format!("040000 tree {creds_tree}\tWebAuthn\n"), + ); + let provenance_tree = git_with_stdin( + repo, + &["mktree"], + &format!("040000 tree {empty_tree}\t{provenance}\n"), + ); + let root = git_with_stdin( + repo, + &["mktree"], + &format!( + "100644 blob {principal_blob}\tprincipal\n\ + 040000 tree {empty_tree}\tvalid_after\n\ + 040000 tree {empty_tree}\tvalid_before\n\ + 040000 tree {trust_tree}\ttrust\n\ + 040000 tree {provenance_tree}\tprovenance\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 a `Revocations` document out at [`crate::revocations::REVOKED_REF`] as +/// the real on-disk format after the revocations migration, at the ref's tree +/// root — a bare scalar-keyed map, no wrapper struct: a `<fingerprint>/reason` +/// blob per entry (the map value is a `RevocationBody` subtree, not a bare +/// blob). Asserts the loader still reads the format independent of the +/// writer. +pub(crate) fn write_revocations_doc(repo: &Path, revoked: &[(&str, &str)]) { + let mut entries = String::new(); + for (fingerprint, reason) in revoked { + let reason_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], reason); + let body_tree = git_with_stdin( + repo, + &["mktree"], + &format!("100644 blob {reason_blob}\treason\n"), + ); + entries.push_str(&format!("040000 tree {body_tree}\t{fingerprint}\n")); + } + let revoked_tree = git_with_stdin(repo, &["mktree"], &entries); + let commit = git_with_stdin(repo, &["commit-tree", &revoked_tree, "-m", "fixture"], ""); + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(["update-ref", crate::revocations::REVOKED_REF, &commit]) + .status() + .unwrap(); + assert!(status.success()); +} + +/// Run git in `repo` with `input` on stdin, returning its trimmed stdout. +fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String { + git_store::test_support::git_with_stdin(repo, args, input) +}