refactor: extract git-member crate for the Signed push data model
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
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/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)
+}