git-ents.gitmain
⌘K
foforge
commit 267b097
docs: add tracey requirement annotations across the workspace

Traces all 70 docs/spec/*/.adoc requirements to their implementing and testing code via r[impl …​] / r[verify …​] comments, so tracey_validate/tracey_status can report live coverage instead of 0/70. Comment-only; no logic changes. 65/70 now have an impl reference; the remaining 5 (compat.edition, namespace.url, nonfunctional.no-panic, nonfunctional.no-unsafe, web.auth.webauthn-onboarding) have no .rs home or are unimplemented.

docs: trace anchor.adoc requirements (comments.anchor, comments.projection) docs: trace meta-ref.adoc requirements (storage/members/revocations/account/config/issues/comments) docs: trace signed-push.adoc requirements (auth., web.auth.) docs: trace checks.adoc requirements (checks.) docs: trace server.adoc requirements (protocol/namespace/compat/deploy/nonfunctional) docs: trace web.adoc requirements (web.) docs: trace cli.adoc requirements (cli.*) 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

crates/git-anchor/src/lib.rs @@ -109,6 +109,7 @@ /// Where in the repository something was attached — authoritative at creation /// and never mutated afterwards. Projection onto other commits is always a /// read-time view derived from this record. +// r[impl comments.anchor] #[derive(Debug, Clone, PartialEq, Eq, Facet)] pub struct Anchor { /// The commit the anchor was created against. @@ -123,6 +124,7 @@ } /// Where an [`Anchor`] sits on a target commit, as computed by [`project`]. +// r[impl comments.projection] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Projection { /// The target tree holds the anchor's exact blob at its exact path; the @@ -151,6 +153,7 @@ /// `revision` in `repo`, resolving the revision to a full commit id and /// recording the file's blob id. Fails when the path is not a file at that /// commit or the range does not fit it. +// r[impl comments.anchor] - validates path and line range at creation pub fn capture( repo: &Path, revision: &str, @@ -187,6 +190,7 @@ /// The exact text of `anchor`'s lines — the whole file for a whole-file /// anchor — derived at read time from the content-addressed blob the anchor /// names, so it can never disagree with what was anchored. +// r[impl comments.anchor] - text derived at read time, never stored redundantly pub fn snippet(repo: &Path, anchor: &Anchor) -> Result<String, Error> { let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; let blob = ObjectId::try_from(&anchor.blob) @@ -225,6 +229,7 @@ /// target's with rename tracking to find where the file went, and the line /// range is mapped through the blob diff's hunks — shifted past edits that /// land entirely outside it, [`Projection::Outdated`] when an edit touches it. +// r[impl comments.projection] pub fn project(repo: &Path, anchor: &Anchor, target: &str) -> Result<Projection, Error> { let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; let anchor_blob = ObjectId::try_from(&anchor.blob) @@ -369,6 +374,7 @@ /// the range — including an insertion strictly inside it — means the anchored /// region itself changed, reported as `None` (outdated) rather than guessed /// at. +// r[impl comments.projection] - line-range shifting through blob diff hunks fn map_range(old: &[u8], new: &[u8], range: LineRange) -> Option<LineRange> { // Work in 0-based half-open line coordinates, as the hunks do. Everything // stays unsigned: the shift is tallied as lines added and lines removed @@ -426,6 +432,7 @@ Some(LineRange { start, end }) } + // r[verify comments.anchor] #[test] fn capture_records_the_commit_and_blob_and_snippet_derives_the_text() { let dir = repo(); @@ -440,6 +447,7 @@ assert_eq!(snippet(dir.path(), &anchor).unwrap(), "line 3\nline 4\n"); } + // r[verify comments.anchor] #[test] fn capture_rejects_a_missing_path_and_an_oversized_range() { let dir = repo(); @@ -456,6 +464,7 @@ )); } + // r[verify comments.projection] #[test] fn unchanged_file_projects_as_current() { let dir = repo(); @@ -472,6 +481,7 @@ ); } + // r[verify comments.projection] #[test] fn an_edit_above_the_range_shifts_it() { let dir = repo(); @@ -492,6 +502,7 @@ ); } + // r[verify comments.projection] #[test] fn an_edit_inside_the_range_is_outdated() { let dir = repo(); @@ -511,6 +522,7 @@ ); } + // r[verify comments.projection] #[test] fn a_pure_rename_relocates_with_the_same_lines() { let dir = repo(); @@ -530,6 +542,7 @@ ); } + // r[verify comments.projection] #[test] fn a_rename_with_an_edit_above_relocates_and_shifts() { let dir = repo(); @@ -551,6 +564,7 @@ ); } + // r[verify comments.projection] #[test] fn a_deleted_file_projects_as_deleted() { let dir = repo(); @@ -568,6 +582,7 @@ ); } + // r[verify comments.projection] #[test] fn a_whole_file_anchor_survives_a_modification() { let dir = repo(); @@ -589,6 +604,7 @@ ); } + // r[verify comments.projection] #[test] fn projection_works_backwards_onto_an_ancestor() { let dir = repo(); @@ -610,6 +626,7 @@ ); } + // r[verify comments.projection] #[test] fn map_range_handles_edges() { let old = b"a\nb\nc\nd\n".as_slice();
crates/git-comment/src/lib.rs @@ -28,12 +28,17 @@ use git_anchor::{Anchor, Projection}; use git_store::Provenance; +// r[impl comments.ref] /// The namespace under which comments are recorded: one ref, /// `refs/meta/comments/<id>`, per comment. pub const COMMENTS_NS: &str = "refs/meta/comments"; /// One comment stored at `refs/meta/comments/<id>`. Author and timestamp are /// deliberately absent: they live on the ref's commits (see [`provenance`]). +// r[impl comments.ref] +// r[impl comments.authorship] - body/anchor/issue only, author and timestamps deliberately absent +// r[impl comments.anchor] - carries the anchor a comment was written against +// r[impl comments.projection] - the anchor is retained regardless of projection outcome, so the comment is never lost #[derive(Debug, Clone, PartialEq, Eq, Facet)] pub struct Comment { /// The comment's body text. @@ -62,6 +67,8 @@ /// Write `comment` to `refs/meta/comments/<id>` in `repo` as a new commit /// authored by `author` (a `(name, email)` pair), so the ref's commit chain is /// the comment's edit history and carries its authorship. +// r[impl comments.ref] +// r[impl comments.authorship] - stamps the acting author on the commit, not the document tree pub fn store( repo: &Path, id: &str, @@ -84,6 +91,7 @@ /// Who created and who last updated the comment at `id`, recovered from its /// ref's commit chain, or `None` when no such comment exists. +// r[impl comments.authorship] - recovers the creator and last editor from the commit chain pub fn provenance(repo: &Path, id: &str) -> Result<Option<Provenance>, git_store::Error> { git_store::Store::open(repo)?.item_provenance(COMMENTS_NS, id) } @@ -91,6 +99,7 @@ /// Where `comment`'s anchor sits on `target` (a revision in `repo`): still /// [`Projection::Current`], relocated to a new path or shifted lines, outdated /// because the anchored region was edited, or gone with its file. +// r[impl comments.projection] pub fn project( repo: &Path, comment: &Comment, @@ -129,6 +138,7 @@ const AUTHOR: (&str, &str) = ("alice", "alice@example.com"); + // r[verify comments.ref] #[test] fn store_then_load_round_trips_a_comment() { let dir = repo(); @@ -173,6 +183,7 @@ assert_ne!(a_id, new_id(None, &b).unwrap()); } + // r[verify comments.authorship] #[test] fn provenance_comes_from_the_commits_not_the_document() { let dir = repo(); @@ -191,6 +202,8 @@ assert!(provenance.created.seconds > 0); } + // r[verify comments.anchor] + // r[verify comments.projection] #[test] fn a_stored_comment_projects_onto_the_commit_it_was_written_against() { let dir = repo(); @@ -223,6 +236,8 @@ ); } + // r[verify comments.anchor] - on-disk anchor shape (commit/path/blob/lines) + // r[verify storage.meta-ref] - hand-built fixture load test for the Comment document #[test] fn loads_the_on_disk_comment_format() { // A fixture written as the real on-disk layout — a `body` blob, an
crates/git-ents-core/src/account.rs @@ -13,10 +13,12 @@ use crate::component; +// r[impl account.ref] /// The ref whose tree holds the account profile, and whose mere presence marks a /// repository as an account repo. pub const ACCOUNT_REF: &str = "refs/meta/account"; +// r[impl account.ref] /// A repository's account profile, stored at [`ACCOUNT_REF`]. #[derive(Debug, Clone, Default, PartialEq, Eq, Facet)] pub struct Account { @@ -64,6 +66,7 @@ /// when the repo is not (yet) an account repo. Mirrors the genesis-key idiom /// `issues::new_id` uses: an identifier derived from content, never a stored /// field. +// r[impl account.genesis] pub fn genesis(repo: &Path) -> Result<Option<String>, git_store::Error> { git_store::Store::open(repo)? .history::<Account>(ACCOUNT_REF)? @@ -96,6 +99,7 @@ } } + // r[verify account.ref] #[test] fn store_then_load_round_trips_the_account() { let repo = unique_repo(); @@ -120,6 +124,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify storage.meta-ref] - hand-built fixture load test for the Account document #[test] fn loads_the_on_disk_account_format() { // A fixture written as the real on-disk layout — `username`,
crates/git-ents-core/src/checks.rs @@ -35,6 +35,7 @@ /// identity, so it is not duplicated inside the body. `pub` only because it /// is [`component::MapDocument::Body`] for [`Check`]; nothing outside this /// module constructs one directly. +// r[impl checks.definition] #[derive(Debug, Clone, PartialEq, Eq, Facet)] pub struct CheckBody { /// The shell command run for the check (e.g. `cargo fmt --check`), or @@ -136,6 +137,8 @@ /// toolchain actually exists is checked server-side at job time, not here — /// unlike `depends`, `toolchains` cross-references a different ref /// namespace this function has no set of configured names to check against. +// r[impl checks.definition] static DAG validation: cycles, dangling deps, self edges, no-command-no-deps, rejected images +// r[impl checks.toolchains] toolchain name must be a valid ref-path segment at write time pub fn order(checks: &[Check]) -> Result<Vec<&Check>, String> { let mut by_name: std::collections::BTreeMap<&str, &Check> = std::collections::BTreeMap::new(); for check in checks { @@ -221,6 +224,7 @@ /// A check run's status, progressing `Queued` → `Running` → a terminal /// outcome. Closed set — the only values a run legitimately takes, in place /// of a `String` that every caller had to trust held one of five values. +// r[impl checks.outcomes] status progression queued->running->pass/fail/error/skipped #[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)] #[repr(u8)] pub enum Status { @@ -255,6 +259,7 @@ /// One check's on-disk outcome. The map key (the check's name) is not /// duplicated inside it. Optional fields absent from an older record load as /// unset, so a run recorded before a field existed still loads. +// r[impl checks.outcomes] optional metadata fields; name is the map key, not stored redundantly #[derive(Debug, Clone, PartialEq, Eq, Facet)] struct Outcome { /// `queued`, `running`, then `pass`, `fail`, or `error`. @@ -312,6 +317,7 @@ /// Record a run of `outcomes` for `commit` in `repo`, as a new commit on /// `refs/meta/runs/<commit>`, parented on the prior run so the ref's commit /// chain is the run history. The commit's date is the run time. +// r[impl checks.outcomes] one ref per commit at refs/meta/runs/<commit>; commit chain is history; commit date is run time pub fn record( repo: &Path, commit: ObjectId, @@ -334,6 +340,7 @@ /// /// When no run has been recorded yet the update starts one, so a worker that /// advances a run is self-healing even if the `queued` record never landed. +// r[impl checks.outcomes] progresses a run's status in place (queued->running->terminal) pub fn update_run( repo: &Path, commit: ObjectId, @@ -454,6 +461,7 @@ } } + // r[verify checks.definition] #[test] fn store_then_load_round_trips_the_check_set() { let repo = unique_repo(); @@ -545,6 +553,7 @@ } } + // r[verify checks.outcomes] #[test] fn record_then_runs_round_trips_a_run() { let repo = unique_repo(); @@ -567,6 +576,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify checks.outcomes] #[test] fn recording_a_commit_again_appends_a_run() { let repo = unique_repo(); @@ -595,6 +605,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify checks.outcomes] #[test] fn round_trips_an_outcomes_duration_and_recording() { let repo = unique_repo(); @@ -612,6 +623,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify checks.outcomes] #[test] fn update_run_advances_in_place_rather_than_appending() { let repo = unique_repo(); @@ -628,6 +640,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify checks.outcomes] #[test] fn displays_lowercase_status_words() { assert_eq!(Status::Queued.to_string(), "queued"); @@ -635,6 +648,7 @@ assert_eq!(Status::Skipped.to_string(), "skipped"); } + // r[verify checks.definition] #[test] fn store_then_load_round_trips_image_and_depends() { let repo = unique_repo(); @@ -655,6 +669,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify checks.definition] #[test] fn order_runs_dependencies_first() { let checks = vec![ @@ -670,6 +685,7 @@ assert_eq!(names, vec!["fmt", "test", "ci"]); } + // r[verify checks.definition] rejects a dependency cycle #[test] fn order_rejects_a_cycle() { let checks = vec![ @@ -682,6 +698,7 @@ assert!(err.contains('a') && err.contains('b')); } + // r[verify checks.definition] rejects a dangling dependency #[test] fn order_rejects_an_unknown_dependency() { let checks = vec![dependent("test", "cargo nextest run", &["fmt"])]; @@ -689,6 +706,7 @@ assert!(err.contains("unknown check fmt"), "unexpected error: {err}"); } + // r[verify checks.definition] rejects self and duplicate edges #[test] fn order_rejects_self_and_duplicate_edges() { let selfish = vec![dependent("a", "true", &["a"])]; @@ -700,6 +718,7 @@ assert!(order(&doubled).unwrap_err().contains("twice")); } + // r[verify checks.definition] rejects a check with neither a command nor dependencies #[test] fn order_rejects_an_empty_check() { let checks = vec![composite("hollow", &[])]; @@ -710,6 +729,7 @@ ); } + // r[verify checks.toolchains] #[test] fn order_accepts_a_valid_toolchain_name() { let checks = vec![toolchained("build", "make", &["gcc-12"])]; @@ -723,6 +743,7 @@ ); } + // r[verify checks.toolchains] #[test] fn order_rejects_an_invalid_toolchain_name() { let checks = vec![toolchained("build", "make", &["not/valid"])]; @@ -730,6 +751,7 @@ assert!(err.contains("invalid toolchain"), "unexpected error: {err}"); } + // r[verify checks.toolchains] #[test] fn store_then_load_round_trips_toolchains() { let repo = unique_repo();
crates/git-ents-core/src/config.rs @@ -15,9 +15,11 @@ use crate::component; +// r[impl config.ref] /// The ref whose tree holds the repository configuration. pub const CONFIG_REF: &str = "refs/meta/config"; +// r[impl config.ref] /// The repository configuration stored at [`CONFIG_REF`]. #[derive(Debug, Clone, Default, PartialEq, Eq, Facet)] pub struct Config { @@ -109,6 +111,7 @@ /// An absent ref yields [`Config::default`], as on a repository whose metadata /// has not been set yet. A present but unreadable ref is an error so callers can /// distinguish corruption from "no configuration set". +// r[impl config.ref] - an absent ref yields the default (all empty) pub fn load_with(store: &git_store::Store) -> Result<Config, git_store::Error> { Ok(component::load::<Config>(store)?.unwrap_or_default()) } @@ -158,6 +161,7 @@ } } + // r[verify config.ref] #[test] fn store_then_load_round_trips_the_config() { let repo = unique_repo(); @@ -175,6 +179,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify storage.meta-ref] - hand-built fixture load test for the Config document #[test] fn loads_the_on_disk_config_format() { // A fixture written as the real on-disk layout — `description` and @@ -193,6 +198,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify config.ref] #[test] fn default_when_the_config_ref_is_absent() { let repo = unique_repo();
crates/git-ents-core/src/issues.rs @@ -31,6 +31,7 @@ use crate::component; +// r[impl issues.ref] /// The namespace under which issues are recorded: one ref, /// `refs/meta/issues/<id>`, per issue. pub const ISSUES_NS: &str = "refs/meta/issues"; @@ -50,6 +51,7 @@ Closed, } +// r[impl issues.ref] /// One issue stored at `refs/meta/issues/<id>`. #[derive(Debug, Clone, PartialEq, Eq, Facet)] pub struct Issue { @@ -97,6 +99,7 @@ /// issue derives from one — one origin, one issue, deduplicated on /// provenance — otherwise the hash of the issue's own initial content, since /// every issue is a git object and so always has one. +// r[impl issues.id] pub fn new_id(origin: Option<&str>, content: &Issue) -> Result<String, git_store::Error> { git_store::new_id(origin, content) } @@ -154,6 +157,7 @@ /// callers believe they claimed it. A CAS conflict here is retried by /// re-reading the counter, so the number handed back is always the one /// actually reserved for this call. +// r[impl issues.id] - only promotion advances the friendly-number counter, never renaming the ref pub fn promote(repo: &Path, id: &str) -> Result<String, PromoteError> { let store = git_store::Store::open(repo)?; let mut number = None; @@ -208,6 +212,7 @@ } } + // r[verify issues.ref] #[test] fn store_then_load_round_trips_an_issue() { let repo = unique_repo(); @@ -236,6 +241,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify storage.meta-ref] - hand-built fixture load test for the Issue document #[test] fn loads_the_on_disk_issue_format() { // A fixture written as the real on-disk layout — `title`, `body`, @@ -260,12 +266,14 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify issues.id] #[test] fn new_id_uses_the_origin_when_one_is_given() { let content = issue("A bug", State::Open, &[]); assert_eq!(new_id(Some("deadbeef"), &content).unwrap(), "deadbeef"); } + // r[verify issues.id] #[test] fn new_id_hashes_its_own_content_with_no_origin() { let a = issue("A bug", State::Open, &[]); @@ -278,6 +286,7 @@ assert_ne!(a_id, b_id); } + // r[verify issues.id] #[test] fn filing_an_issue_leaves_its_friendly_number_unset() { let repo = unique_repo(); @@ -288,6 +297,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify issues.id] #[test] fn promotion_assigns_a_number_and_advances_the_counter_without_renaming_the_ref() { let repo = unique_repo();
crates/git-ents-core/src/members.rs @@ -40,6 +40,7 @@ use crate::component; +// r[impl members.ref] /// The namespace whose refs hold the member set — the push trust root. One /// `refs/meta/member/<username>` ref per person. pub const MEMBER_NS: &str = "refs/meta/member"; @@ -50,6 +51,11 @@ format!("{MEMBER_NS}/{username}") } +// r[impl members.ref] +// r[impl members.trust] +// r[impl members.provenance] +// r[impl members.account] +// r[impl members.window] /// One member: a person named by their `refs/meta/member/<principal>` ref, the /// window their trust holds within, and the keys (or, later, CA) it rests on. #[derive(Debug, Clone, PartialEq, Eq, Facet)] @@ -93,6 +99,7 @@ /// Whether a member was admin-registered or self-attested via web onboarding. /// Defaults to [`Provenance::AdminRegistered`] so a member ref written before /// this field existed loads unchanged. +// r[impl members.provenance] - loads before the field existed as admin-registered #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Facet)] #[repr(u8)] pub enum Provenance { @@ -120,6 +127,7 @@ /// keys, so rotation, expiry, and new devices cost zero downstream edits; it is /// a security win only when the CA lives off the device (hardware token, /// offline, or a remote issuer behind SSO). +// r[impl members.trust] - the three mutually exclusive trust bases #[derive(Debug, Clone, PartialEq, Eq, Facet)] #[repr(u8)] pub enum Trust { @@ -235,6 +243,7 @@ /// [`store`] checks this before every write, so it holds regardless of /// which caller builds the member (the CLI today, an admin web action /// later). + // r[impl members.window] - a window must be well-formed and not inverted pub fn validate(&self) -> Result<(), String> { for bound in [&self.valid_after, &self.valid_before] { if let Some(value) = bound @@ -341,6 +350,8 @@ /// drops out entirely, so a push it would have authorized fails closed. A member /// resting on a CA is untouched — a compromised CA is revoked by removing its /// member ref, since a CA is named by a ref rather than listed by fingerprint. +// r[impl revocations.ref] - subtracts every revoked fingerprint from the trust set +// r[impl revocations.ca] - a CA-trusted member is left untouched; revocation list is leaf-key-only #[must_use] pub fn without_revoked(members: Vec<Member>, revoked: &BTreeSet<String>) -> Vec<Member> { members @@ -369,6 +380,7 @@ /// `allowed_signers` options so git enforces expiry: out-of-window keys are not /// accepted. Options are comma-joined, the syntax OpenSSH requires for more than /// one. +// r[impl members.allowed-signers] #[must_use] pub fn allowed_signers(members: &[Member]) -> String { members.iter().flat_map(member_lines).collect::<String>() @@ -437,6 +449,7 @@ .collect() } + // r[verify members.ref] #[test] fn store_then_load_round_trips_a_member() { let repo = unique_repo(); @@ -484,6 +497,8 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify storage.meta-ref] - hand-built fixture load test for the Member document + // r[verify members.provenance] #[test] fn loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered() { // A fixture written as the real `member/<username>` layout — a `principal` @@ -512,6 +527,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify members.trust] - WebAuthn credential set round trip #[test] fn loads_a_hand_built_webauthn_fixture() { // A hand-built `trust/WebAuthn/<credential_id>/{cose_key,label}` @@ -543,6 +559,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify members.trust] - WebAuthn authorizes web sign-in only, never allowed_signers/push #[test] fn store_then_load_round_trips_a_webauthn_member() { let repo = unique_repo(); @@ -565,6 +582,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify members.allowed-signers] - wildcard principal, namespaces="git" #[test] fn renders_a_wildcard_allowed_signers_file() { let member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); @@ -574,6 +592,7 @@ ); } + // r[verify members.allowed-signers] - validity window as comma-joined options #[test] fn renders_the_validity_window_as_comma_joined_options() { let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); @@ -587,6 +606,7 @@ ); } + // r[verify members.trust] - certificate authority trust basis #[test] fn store_then_load_round_trips_a_ca_member() { let repo = unique_repo(); @@ -599,6 +619,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify revocations.ref] #[test] fn without_revoked_drops_revoked_keys_and_emptied_members() { let alice = Member::with_keys( @@ -617,6 +638,7 @@ ); } + // r[verify revocations.ca] #[test] fn without_revoked_leaves_ca_members_untouched() { let member = Member::with_ca("alice".to_owned(), KEY_A.to_owned()); @@ -627,6 +649,7 @@ ); } + // r[verify members.allowed-signers] - a pinned CA renders as a cert-authority line #[test] fn renders_a_pinned_ca_as_a_cert_authority_line() { let mut member = Member::with_ca("alice".to_owned(), KEY_A.to_owned()); @@ -637,6 +660,7 @@ ); } + // r[verify members.window] #[test] fn validate_rejects_a_malformed_timestamp() { let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); @@ -644,6 +668,7 @@ assert!(member.validate().is_err()); } + // r[verify members.window] #[test] fn validate_rejects_an_inverted_window() { let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); @@ -660,6 +685,7 @@ member.validate().unwrap(); } + // r[verify members.window] #[test] fn store_rejects_a_member_with_an_inverted_window() { let repo = unique_repo();
crates/git-ents-core/src/revocations.rs @@ -30,6 +30,7 @@ use crate::component; +// r[impl revocations.ref] /// The ref whose tree holds the revocation list — the deny overlay on the trust /// set. pub const REVOKED_REF: &str = "refs/meta/revoked"; @@ -134,6 +135,7 @@ } } + // r[verify revocations.ref] #[test] fn store_then_load_round_trips_the_revocations() { let repo = unique_repo(); @@ -167,6 +169,7 @@ let _ = std::fs::remove_dir_all(&repo); } + // r[verify storage.meta-ref] - hand-built fixture load test for the Revocation document #[test] fn loads_the_on_disk_revoked_format() { // A fixture written as the real `revoked/<fingerprint>/reason` subtree
crates/git-ents-core/tests/cert_authority.rs @@ -22,6 +22,7 @@ /// The principal the CA certifies and the verifier checks — the pusher identity. const PRINCIPAL: &str = "tester@example.com"; +// r[verify members.trust] - a pinned CA's certs verify; an unpinned CA's do not #[test] fn a_cert_from_the_pinned_ca_verifies_and_an_unpinned_one_does_not() { let Some(agent) = Agent::start() else {
crates/git-ents-server/src/asciidoc.rs @@ -25,6 +25,8 @@ /// Render AsciiDoc `source` to an embedded HTML fragment, or `None` if it cannot /// be parsed or converted. The fragment carries no document frame, so callers /// place it inside their own container. +// r[impl web.render-registry] - AsciiDoc's entry in the MIME-keyed registry +// r[impl web.syntax-highlight] - AsciiDoc rendered to HTML pub(crate) fn to_html(source: &str) -> Option<String> { let parsed = acdc_parser::parse(source, &ParseOptions::default()).ok()?; let doc = parsed.document();
crates/git-ents-server/src/checks.rs @@ -106,6 +106,8 @@ /// The hook does no check work itself: it writes one job file per updated branch /// into the shared queue directory ([`QUEUE_ENV`]) and returns, so the push is /// never blocked on a Sprite. The server's [`worker`] picks the jobs up. +// r[impl checks.post-receive] enqueues one job per updated non-meta branch with a non-zero tip, records `queued` immediately, does not run checks itself, returns fast +// r[impl nonfunctional.push-latency] - writes job files and returns without running any check pub fn post_receive() -> Result<(), String> { let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?; @@ -176,6 +178,8 @@ /// every other repository's checks; isolating them by repository keeps a slow /// repository's backlog from blocking the rest. Jobs for *one* repository stay /// serialized so concurrent runs never collide in its single Sprite. +// r[impl checks.worker] persistent worker drains the queue; jobs grouped by repo, serialized per-repo, concurrent across repos +// r[impl nonfunctional.concurrency] - check jobs run via `spawn_blocking`, never on the async runtime's own thread pub async fn worker(queue: PathBuf, live: LiveRegistry) { if let Err(e) = std::fs::create_dir_all(&queue) { eprintln!("checks: could not create queue directory {queue:?}: {e}"); @@ -250,6 +254,7 @@ /// re-validation) finalizes the run as `error` rather than leaving it stuck at /// `running`, then returns `Err`. Returns `Ok` even when a check fails — a /// failing check is a recorded result, not an error. +// r[impl checks.worker] checks settle in topological order, skipped-on-failed-dependency semantics, composite outcome derivation, re-validates the DAG before running, finalizes as error if invalid fn process_job(job: &Job, live: &LiveRegistry) -> Result<(), String> { let runnable = checks::load(&job.repo).map_err(|e| format!("could not read checks: {e}"))?; if runnable.is_empty() { @@ -351,6 +356,7 @@ /// A composite check's status, derived from its dependencies' settled /// statuses: `pass` when everything passed, `fail` when anything failed or /// errored, `skipped` when nothing failed but something was skipped. +// r[impl checks.worker] composite outcome derivation from dependency statuses fn derive_composite(deps: &[Status]) -> Status { if deps.iter().all(|status| *status == Status::Pass) { Status::Pass @@ -374,6 +380,7 @@ /// Mark every check in `outcomes` `error` and record it — the terminal state for /// a run the worker could not carry out. +// r[impl checks.worker] finalizes a run as error when it cannot be carried out fn finalize_error(repo: &Path, new: ObjectId, outcomes: &mut [RunOutcome]) { for outcome in outcomes.iter_mut() { outcome.status = Status::Error; @@ -392,6 +399,7 @@ /// Write a job for `update` into `queue` as a three-line file (`repo`, new oid, /// ref). The file is written under a `.tmp` name and renamed into place so the /// worker never observes a half-written job. +// r[impl checks.post-receive] job files are written to a tmp name then renamed into place fn enqueue(queue: &Path, repo: &Path, update: &Update) -> Result<(), String> { std::fs::create_dir_all(queue) .map_err(|e| format!("could not create queue directory {queue:?}: {e}"))?; @@ -458,6 +466,7 @@ /// /// Shared with [`crate::web`]'s debug-session broker, which targets the same /// persistent per-repo Sprite a check run used. +// r[impl checks.sandbox] each check runs in a Fly.io Sprite per repo pub(crate) fn sprite_name(repo: &Path) -> String { let stem = repo .file_name() @@ -485,6 +494,8 @@ /// token per call, so without this it reports "no organizations configured" /// even with the token in the environment. `auth setup` is idempotent, so it is /// run on every push to keep the steady state self-healing. +// r[impl checks.sandbox] configures the sprite CLI from SPRITES_TOKEN +// r[impl compat.sprite] - `sprite auth setup --token` from SPRITES_TOKEN, run per-push since credentials persist to a config file pub(crate) fn ensure_auth() -> Result<(), String> { let token = std::env::var("SPRITES_TOKEN") .ok() @@ -507,6 +518,8 @@ /// fails when the Sprite is already there, which is the steady state once the /// first push has run, so its failure is tolerated and surfaces only later if /// the Sprite turns out to be unreachable. +// r[impl checks.sandbox] creates the repository's per-repo Sprite +// r[impl compat.sprite] - `sprite create` on PATH at runtime pub(crate) fn ensure_sprite(sprite: &str) -> Result<(), String> { let _existing = Command::new("sprite") .args(["create", "--skip-console", sprite]) @@ -519,6 +532,9 @@ /// previous contents while leaving the rest of the persistent filesystem (build /// caches and the like) intact. `git archive` emits the tree as a tar that the /// Sprite unpacks over stdin. +// r[impl checks.sandbox] syncs the pushed tree via git archive | tar -x +// r[impl compat.sprite] - `sprite exec` on PATH at runtime +// r[impl compat.git] - invokes `git archive` as an external subprocess fn sync_tree(repo: &Path, sprite: &str, new: ObjectId) -> Result<(), String> { let archive = Command::new("git") .arg("-C") @@ -557,6 +573,8 @@ /// failed resolution (the named ref does not exist) is the one place /// `checks::order` could not have caught it, since `refs/meta/toolchains/*` /// is a different namespace than the check set itself. +// r[impl checks.toolchains] existence of a named toolchain is checked server-side at job time +// r[impl checks.sandbox] resolves toolchain bin trees and extracts into a hash-keyed dir, with caching fn resolve_toolchains( repo: &Path, sprite: &str, @@ -610,6 +628,7 @@ /// `bin` directories, declared order first (so the first-listed toolchain's /// `bin` wins on a name collision); a check with no toolchains is returned /// unchanged. +// r[impl checks.sandbox] PATH prefixing of resolved toolchains in declaration order fn activate(command: &str, toolchains: &[String], dirs: &HashMap<String, String>) -> String { if toolchains.is_empty() { return command.to_owned(); @@ -629,6 +648,7 @@ /// persistent filesystem is the cache. Checked before running `git archive` /// so an already-cached toolchain never streams its (potentially large) /// contents through a pipe the Sprite has no reason to read. +// r[impl checks.sandbox] extracts an embedded toolchain into a hash-keyed dir, cached fn sync_toolchain(repo: &Path, sprite: &str, tree: ObjectId) -> Result<(), String> { let dir = format!("{TOOLCHAINS_DIR}/{tree}"); let cached = Command::new("sprite") @@ -687,6 +707,7 @@ /// mirroring `git_toolchain::export`'s local equivalent: downloading through /// the server first and streaming the bytes in would defeat the point of not /// storing them. +// r[impl checks.sandbox] extracts a downloaded toolchain into a hash-keyed dir, cached fn sync_downloaded_toolchain( sprite: &str, key: &str, @@ -738,6 +759,7 @@ /// killed and recorded `error` rather than wedging the worker (and with it every /// other repository's checks) on the one blocking-pool thread the queue drains /// on. +// r[impl checks.outcomes] timeout at 30 minutes finalizes a check as error const CHECK_TIMEOUT: Duration = Duration::from_secs(30 * 60); /// The fixed size a check's recorded terminal session runs at. Nothing @@ -769,6 +791,7 @@ /// as the recorded `recording`, not a separate representation of the same /// output. Returns the check's outcome; a check that exceeds [`CHECK_TIMEOUT`] /// or cannot be captured is [`Status::Error`]. +// r[impl compat.sprite] - `sprite exec` (with `--tty`) on PATH at runtime fn run_one(sprite: &str, name: &str, command: &str, live: &Arc<StdMutex<String>>) -> RunResult { let start = Instant::now(); lock(live).push_str(&asciicast_header()); @@ -926,6 +949,7 @@ use super::*; + // r[verify checks.post-receive] only updated non-meta branches with a non-zero tip are enqueued #[test] fn parse_updates_keeps_content_branches_only() { let new = "1111111111111111111111111111111111111111"; @@ -941,12 +965,14 @@ assert_eq!(refs, vec!["refs/heads/main", "refs/heads/feature"]); } + // r[verify checks.sandbox] #[test] fn activate_leaves_a_toolchain_free_command_unchanged() { let dirs = HashMap::new(); assert_eq!(activate("cargo test", &[], &dirs), "cargo test"); } + // r[verify checks.sandbox] PATH prefixing in declaration order #[test] fn activate_prefixes_path_in_declared_order() { let mut dirs = HashMap::new(); @@ -959,6 +985,7 @@ ); } + // r[verify checks.sandbox] #[test] fn activate_skips_a_toolchain_missing_from_dirs() { let dirs = HashMap::new(); @@ -969,6 +996,7 @@ ); } + // r[verify checks.worker] composite outcome derivation #[test] fn composite_status_derives_from_its_dependencies() { assert_eq!( @@ -992,6 +1020,7 @@ assert_eq!(derive_composite(&[]), Status::Pass); } + // r[verify checks.worker] jobs grouped by repo for per-repo draining #[test] fn pending_jobs_groups_by_repo_and_drops_malformed() { let queue = tempfile::tempdir().unwrap();
crates/git-ents-server/src/http.rs @@ -19,11 +19,13 @@ const CGI_HEADER_SEP: &[u8] = b"\r\n\r\n"; +// r[impl deploy.health] /// A liveness probe (and the `/` root) that does not touch git. pub async fn health() -> &'static str { "ok" } +// r[impl protocol.routing] - dispatches a GET to the web UI or the git backend by path/query /// Serve a GET: the HTML web UI for browser requests, or `git http-backend` for /// a git wire-protocol read (the ref advertisement or a dumb-HTTP object fetch). pub async fn get_request(State(state): State<AppState>, uri: Uri, headers: HeaderMap) -> Response { @@ -53,6 +55,7 @@ .await } +// r[impl protocol.routing] - dispatches a POST to the web UI or the git backend by path /// Serve a POST: always a git smart-HTTP RPC (`git-upload-pack` for fetch or /// `git-receive-pack` for push). The browser UI never POSTs, so there is no web /// branch here. @@ -86,11 +89,16 @@ .await } +// r[impl protocol.routing] /// Whether a POST is a git smart-HTTP RPC rather than a browser form submission. fn is_git_post(path_info: &str) -> bool { path_info.ends_with("/git-upload-pack") || path_info.ends_with("/git-receive-pack") } +// r[impl protocol.git] - delegates the git wire protocol to `git http-backend` as a CGI subprocess +// r[impl compat.git] - invokes `git http-backend` as an external subprocess, GIT_PROJECT_ROOT on PATH +// r[impl compat.cgi] - populates the CGI environment before spawning `git http-backend` +// r[impl nonfunctional.concurrency] - writes stdin and drains stdout concurrently to avoid deadlock /// Hand a git wire-protocol request to `git http-backend` and reply with its /// output. A receive-pack request (push) auto-creates its bare repository before /// the backend runs and reconciles `HEAD` after a successful push. @@ -222,6 +230,8 @@ build_response(&stdout) } +// r[impl compat.git] - overrides passed via GIT_CONFIG_* rather than `git -c` so they reach receive-pack/pre-receive +// r[impl auth.nonce] /// The `git` config overrides applied to every backend invocation. Empty until /// push authentication is wired: a seed enables the signed-push nonce, and the /// hooks directory points the backend at the `pre-receive` verifier. @@ -241,6 +251,7 @@ overrides } +// r[impl compat.cgi] - parses the CGI response format (header block, blank line, body) into HTTP /// Translate a CGI response (header block, blank line, body) into HTTP. fn build_response(stdout: &[u8]) -> Response { let (header_block, body) = match find_subsequence(stdout, CGI_HEADER_SEP) { @@ -282,6 +293,7 @@ /// Shared by the push gateway and the web UI's routing/discovery. pub(crate) const MAX_REPO_DEPTH: usize = 3; +// r[impl protocol.routing] - browse routes win over dumb-HTTP path heuristics, service requests never stolen /// Whether a GET should be answered with the HTML web UI rather than handed to /// `git http-backend`. /// @@ -298,6 +310,7 @@ !is_wire || (is_browse && !is_service_request(path, query)) } +// r[impl protocol.routing] /// Whether `path`/`query` is an unambiguous smart-HTTP service request (the /// ref advertisement or an upload-pack/receive-pack RPC). fn is_service_request(path: &str, query: &str) -> bool { @@ -325,6 +338,8 @@ .find_map(|pair| pair.strip_prefix("service=")) } +// r[impl namespace.auto-create] - serializes creation behind `init_lock` so concurrent first pushes cannot race +// r[impl storage.bare] - creates the bare repo automatically on first push /// Ensure `repo` exists as a bare repository, creating it on first push. /// /// Holds [`AppState::init_lock`] across the whole check-and-create so two @@ -360,6 +375,7 @@ }) } +// r[impl namespace.path] - refuses a path nested inside an existing repository /// The ancestor of `repo` (below `data_dir`) that is itself a bare repository, /// if any. Used to refuse creating a repository inside another one. fn enclosing_repo(data_dir: &Path, repo: &Path) -> Option<PathBuf> { @@ -383,6 +399,7 @@ path.join("HEAD").is_file() && path.join("objects").is_dir() } +// r[impl namespace.path] - one to three segments, rejected before `git http-backend` is invoked /// The target repository of a push, as a validated path relative to the data /// directory, or `None` if the request does not name an acceptable repository. /// @@ -406,6 +423,7 @@ Some(segments.into_iter().collect()) } +// r[impl namespace.path] - ASCII alphanumerics plus `.`, `_`, `-`; no leading `.`, no separator /// Whether a single path component is a safe repository/namespace name. /// /// Rejecting any leading `.` rules out `.`, `..`, and hidden directories; the @@ -419,6 +437,8 @@ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) } +// r[impl namespace.auto-create] - `git init --bare` + `http.receivepack = true` on first push +// r[impl compat.git] - invokes `git init --bare` as an external subprocess /// Create a bare repo that accepts pushes over smart-HTTP. async fn init_bare_repo(repo: &Path) -> std::io::Result<()> { let init = Command::new("git") @@ -450,6 +470,8 @@ Ok(()) } +// r[impl namespace.auto-create] - adopts a pushed branch (preferring main, then master) so a fresh clone checks out content +// r[impl compat.git] - invokes `git for-each-ref`/`git symbolic-ref` as external subprocesses /// Point `HEAD` at a real branch when it dangles after a push. /// /// Best-effort: the push already succeeded, so failures here are ignored. @@ -561,6 +583,7 @@ #[case("a/b", false)] #[case("a b", false)] #[case("a%2eb", false)] + // r[verify namespace.path] fn validates_segments(#[case] segment: &str, #[case] expected: bool) { assert_eq!(valid_segment(segment), expected); } @@ -579,11 +602,13 @@ } } + // r[verify auth.nonce] #[test] fn backend_config_is_empty_without_authentication() { assert!(backend_config(&state(None, None)).is_empty()); } + // r[verify auth.nonce] #[test] fn backend_config_injects_nonce_seed_and_hooks_path() { assert_eq!( @@ -605,6 +630,7 @@ #[case("/../etc/git-receive-pack", None)] #[case("/.ssh/git-receive-pack", None)] #[case("/git-receive-pack", None)] + // r[verify namespace.path] fn extracts_repo_path(#[case] path: &str, #[case] expected: Option<&str>) { assert_eq!(repo_path(path).as_deref(), expected.map(Path::new)); } @@ -617,6 +643,7 @@ #[case("/repo.git/info/refs", "x=service=git-receive-pack", false)] #[case("/repo.git/info/refs", "a=b&service=git-receive-pack", true)] #[case("/repo.git/objects/info/packs", "", false)] + // r[verify protocol.routing] fn detects_pushes(#[case] path: &str, #[case] query: &str, #[case] expected: bool) { assert_eq!(is_receive_pack(path, query), expected); } @@ -638,6 +665,7 @@ #[case("/repo/info/refs", "service=git-upload-pack", false)] #[case("/repo/git-upload-pack", "", false)] #[case("/repo/objects/12/abcdef", "", false)] + // r[verify protocol.routing] fn routes_browser_gets(#[case] path: &str, #[case] query: &str, #[case] expected: bool) { assert_eq!(is_web_get(path, query), expected); }
crates/git-ents-server/src/lib.rs @@ -67,6 +67,7 @@ pub checks_queue: Option<PathBuf>, } +// r[impl server.embeddable] - hooks are subcommands of the server, not separate programs /// Subcommands that run instead of serving HTTP. #[derive(Facet, Debug)] #[repr(u8)] @@ -112,6 +113,8 @@ std::env::var(key).ok().filter(|value| !value.is_empty()) } +// r[impl server.embeddable] - shared entry point run by both the standalone binary and `git ents server` +// r[impl deploy.fly] - env-var-first config, since that is how this server is configured on Fly.io /// Run the server: dispatch `pre-receive`/`post-receive`, or serve HTTP. /// `args`' flags win over their matching environment variable, which in turn /// wins over the hardcoded default. @@ -185,11 +188,14 @@ state.live_runs.clone(), )); + // r[impl protocol.routing] - one listener serves both the git wire protocol and the web UI, routed by path + // r[impl deploy.health] - `/healthz` is wired ahead of the catch-all so it never touches a repository // The git smart-HTTP protocol streams whole packfiles through the request // body, so the default 2 MiB cap would reject any non-trivial push. let app = Router::new() .route("/healthz", get(http::health)) .route("/", get(http::get_request)) + // r[impl checks.debug] debug shell brokered over WebSocket at /_debug/<repo> .route("/_debug/{*path}", get(web::handshake)) .route("/{*path}", get(http::get_request).post(http::post_request)) .layer(DefaultBodyLimit::disable())
crates/git-ents-server/src/main.rs @@ -14,9 +14,11 @@ builtins: FigueBuiltins, } +// r[impl server.embeddable] - the standalone binary runs the same `git_ents_server::run` as `git ents server` fn main() -> ExitCode { let raw_args: Vec<String> = std::env::args().skip(1).collect(); + // r[impl deploy.fly] // With zero CLI tokens (how Fly.io always runs this image — every setting // arrives via env vars, which `git_ents_server::run` reads itself), // `#[facet(flatten)]`'s all-`Option` `args` never gets a single populated
crates/git-ents-server/src/markdown.rs @@ -19,6 +19,8 @@ /// Render Markdown `source` to an embedded HTML fragment, with the tables, /// footnotes, strikethrough, and task-list extensions people expect from /// forge-flavored Markdown. +// r[impl web.render-registry] - Markdown's entry in the MIME-keyed registry +// r[impl web.syntax-highlight] - Markdown rendered to HTML pub(crate) fn to_html(source: &str) -> String { let options = Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES
crates/git-ents-server/src/render.rs @@ -29,6 +29,7 @@ /// Render `source` (declared or inferred as `mime`) to an embedded HTML /// fragment. Unrecognized MIME types fall through to an escaped `<pre>` /// block rather than an error. +// r[impl web.render-registry] - HTML path shared by the web UI pub fn to_html(mime: &str, source: &str) -> String { match mime { "text/asciidoc" => asciidoc::to_html(source), @@ -41,6 +42,7 @@ /// Render `source` (declared or inferred as `mime`) to plain text, for /// terminal output. Unrecognized MIME types fall through to `source` /// verbatim. +// r[impl web.render-registry] - plain-text path shared by the CLI pub fn to_text(mime: &str, source: &str) -> String { match mime { "text/asciidoc" => asciidoc::to_text(source),
crates/git-ents-server/src/verify.rs @@ -21,11 +21,14 @@ /// 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 /// environment git populates for the hook. +// r[impl auth.signed-push] +// r[impl compat.openssh-signed-push] - reads GIT_PUSH_CERT / GIT_PUSH_CERT_NONCE_STATUS, the env vars git populates for `pre-receive` pub fn pre_receive() -> Result<(), String> { let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?; let store = git_store::Store::open(&repo).map_err(|e| format!("cannot open store: {e}"))?; let members = members::load_all_with(&store) .map_err(|e| format!("could not read authorized signers: {e}"))?; + // r[impl auth.bootstrap] if members.is_empty() { // No trust list pushed yet: stay open so the first signer can be added. // Revocation is keyed on member refs existing, so revoking every member's @@ -35,6 +38,7 @@ } let revoked = revocations::fingerprints_with(&store) .map_err(|e| format!("could not read revocations: {e}"))?; + // r[impl auth.signed-push] trust set is the live member set minus revoked fingerprints let authorized = members::without_revoked(members, &revoked); let ref_updates = read_ref_updates()?; @@ -43,6 +47,7 @@ .ok_or_else(|| { "this repository requires a signed push: rerun with `git push --signed`".to_owned() })?; + // r[impl auth.signed-push] nonce status must be OK if env("GIT_PUSH_CERT_NONCE_STATUS").as_deref() != Some("OK") { return Err("push certificate nonce was missing or stale".to_owned()); } @@ -94,6 +99,8 @@ /// Split the certificate into its signed payload and SSH signature, then accept /// it only when `ssh-keygen -Y verify` trusts the signature against one of the /// authorized keys. +// r[impl auth.signed-push] SSH signature block + ssh-keygen -Y verify against the trust set +// r[impl compat.ssh-keygen] - `ssh-keygen -Y verify` against a runtime-written `allowed_signers` file fn verify_certificate(authorized: &[Member], certificate: &str) -> Result<(), String> { const MARKER: &str = "-----BEGIN SSH SIGNATURE-----"; let split = certificate @@ -153,6 +160,7 @@ std::env::var(key).ok() } +// r[impl compat.git] - invokes `git cat-file` as an external subprocess /// Read the blob `oid` from `repo` as text. fn cat_blob(repo: &Path, oid: &str) -> Result<String, String> { let output = Command::new("git")
crates/git-ents-server/tests/pre_receive.rs @@ -220,6 +220,9 @@ .success() } +// r[verify auth.signed-push] +// r[verify compat.ssh-keygen] +// r[verify compat.openssh-signed-push] #[test] fn accepts_a_push_signed_by_an_authorized_key() { let base = unique_dir("accept"); @@ -234,6 +237,8 @@ std::fs::remove_dir_all(&base).ok(); } +// r[verify auth.signed-push] +// r[verify compat.ssh-keygen] #[test] fn rejects_a_push_signed_by_an_unknown_key() { let base = unique_dir("unknown"); @@ -249,6 +254,8 @@ std::fs::remove_dir_all(&base).ok(); } +// r[verify auth.signed-push] +// r[verify compat.openssh-signed-push] #[test] fn rejects_an_unsigned_push_when_signers_exist() { let base = unique_dir("unsigned"); @@ -326,6 +333,7 @@ std::fs::remove_dir_all(&base).ok(); } +// r[verify auth.signed-push] trust set excludes revoked fingerprints #[test] fn rejects_a_push_signed_by_a_revoked_key() { // The key is a valid, in-window member, but its fingerprint is on the @@ -344,6 +352,7 @@ std::fs::remove_dir_all(&base).ok(); } +// r[verify auth.bootstrap] #[test] fn accepts_any_push_before_signers_are_configured() { let base = unique_dir("bootstrap");
crates/git-ents-server/tests/server.rs @@ -13,6 +13,9 @@ use rstest::rstest; +// r[verify web.server-rendered] - GET / returns a rendered page, no client required +// r[verify web.index] - GET / renders the repository index +// r[verify server.embeddable] - exercises the standalone `git-ents-server` binary #[test] fn responds_to_requests() { let port = free_port(); @@ -41,6 +44,9 @@ let _wait = child.wait(); } +// r[verify storage.bare] - a first push auto-creates the bare repo, and it survives to be cloned +// r[verify namespace.auto-create] +// r[verify protocol.git] #[test] fn push_then_clone_round_trip() { let data = tempfile::tempdir().unwrap(); @@ -79,6 +85,8 @@ assert_eq!(pushed, cloned, "cloned HEAD must match pushed HEAD"); } +// r[verify namespace.path] - `org/repo` and `org/team/repo` are accepted multi-segment paths +// r[verify namespace.auto-create] #[rstest] #[case("org/repo")] #[case("org/team/repo")] @@ -120,6 +128,7 @@ ); } +// r[verify namespace.auto-create] - a colliding creation is refused, not raced #[rstest] #[case("org/repo.git/deep.git")] // nested inside an existing repository #[case("org")] // already exists as a namespace
crates/git-ents-server/tests/web_edit.rs @@ -20,6 +20,10 @@ const BIN: &str = env!("CARGO_BIN_EXE_git-ents-server"); const LOGIN_NAMESPACE: &str = "git.ents.cloud"; +// r[verify web.tabs] - Settings tab is editable in the browser by a signed-in member +// r[verify web.auth.edit] - lands as a real signed push authored by the member, committed by the server +// r[verify web.auth.challenge] - signs in via the challenge/signature flow first +// r[verify web.auth.session] - the session cookie authorizes the edit #[test] fn a_member_edits_settings_through_the_browser() { let env = Server::start(); @@ -81,6 +85,7 @@ ); } +// r[verify web.auth.session] - a state-changing POST without the matching CSRF token is refused #[test] fn an_edit_without_a_valid_csrf_token_is_refused() { let env = Server::start(); @@ -104,6 +109,7 @@ ); } +// r[verify web.auth.edit] - require_admin_registered refuses a self-attested member's settings edit #[test] fn a_self_attested_member_is_refused_a_settings_edit() { let env = Server::start(); @@ -143,6 +149,7 @@ ); } +// r[verify web.auth.challenge] - a session proves key control, not membership or authority #[test] fn a_non_member_is_not_offered_an_edit_form() { let env = Server::start(); @@ -167,6 +174,7 @@ ); } +// r[verify web.auth.challenge] - a signature that does not match the pasted public key must not open a session #[test] fn a_signature_that_does_not_match_the_public_key_is_refused() { let env = Server::start();
crates/git-ents/src/debug_session.rs @@ -11,6 +11,7 @@ /// Open a debug session at `url`, authenticated with the session `token` /// stored by `git ents login`. +// r[impl checks.debug] `git ents checks debug` CLI side of the interactive shell pub(crate) async fn run(url: &str, token: &str) -> Result<(), String> { let mut request = url .into_client_request() @@ -37,6 +38,7 @@ /// straight to stdout. A dedicated thread reads stdin because raw terminal /// input has no natural way to interrupt a blocking read when the session /// ends from the other side. +// r[impl checks.debug] terminal resize forwarded as control frames async fn pump<S, R>(sink: &mut S, source: &mut R) -> Result<(), String> where S: futures_util::Sink<Message> + Unpin,
crates/git-ents/src/interactive.rs @@ -6,6 +6,7 @@ use std::io::IsTerminal as _; +// r[impl cli.interactive] - TTY detection gating prompting vs. failing fast /// Whether prompting is possible: both stdin and stdout are a terminal. #[must_use] pub fn available() -> bool { @@ -16,6 +17,7 @@ /// error naming `message` when not, so a script never hangs on a missing /// argument. An empty string — whether passed explicitly or typed at the /// prompt — is rejected the same as a missing value. +// r[impl cli.interactive] - prompt for a required field at a TTY; error naming the field otherwise pub fn text_or(existing: Option<String>, message: &str) -> Result<String, String> { if let Some(value) = existing { return if value.is_empty() { @@ -41,6 +43,7 @@ /// `existing`, or an optional text prompt for `message` when interactive — /// an empty reply is `None`. Non-interactive with no `existing` value stays /// `None` rather than erroring, since the field is optional. +// r[impl cli.interactive] - prompt for an optional field at a TTY, `None` otherwise pub fn optional_text_or(existing: Option<String>, message: &str) -> Result<Option<String>, String> { if existing.is_some() { return Ok(existing); @@ -56,6 +59,7 @@ /// A `Select` prompt among `options`, run only when interactive; `default` /// otherwise. +// r[impl cli.interactive] - prompt with a selection at a TTY, `default` otherwise pub fn select_or(message: &str, options: &[&str], default: usize) -> Result<usize, String> { if !available() { return Ok(default);
crates/git-ents/src/main.rs @@ -43,6 +43,11 @@ builtins: FigueBuiltins, } +// r[impl cli.members] - top-level subcommand +// r[impl cli.account-checks] - top-level subcommands +// r[impl cli.toolchains] - top-level subcommand +// r[impl cli.comments] - top-level subcommand +// r[impl cli.login] - top-level subcommand #[derive(Facet)] #[repr(u8)] enum Top { @@ -87,6 +92,7 @@ Server(git_ents_server::Args), } +// r[impl cli.members] - members subcommands (list/add/remove/revoke/unrevoke/check/setup) #[derive(Facet)] #[repr(u8)] enum Action { @@ -166,6 +172,7 @@ }, } +// r[impl cli.account-checks] - `account create` #[derive(Facet)] #[repr(u8)] enum AccountAction { @@ -185,6 +192,7 @@ }, } +// r[impl cli.account-checks] - `checks list`/`add`/`remove` #[derive(Facet)] #[repr(u8)] enum ChecksAction { @@ -227,6 +235,7 @@ Runs, } +// r[impl cli.toolchains] - `import`/`list`/`export`/`remove` #[derive(Facet)] #[repr(u8)] enum ToolchainAction { @@ -307,6 +316,7 @@ }, } +// r[impl cli.comments] - `add`/`list`/`show`/`remove` #[derive(Facet)] #[repr(u8)] enum CommentAction { @@ -405,6 +415,7 @@ } } +// r[impl cli.members] - dispatch fn run_members(action: Action, remote: &str) -> Result<(), String> { match action { Action::Setup { key, local } => setup(key.as_deref(), local), @@ -437,6 +448,7 @@ } } +// r[impl cli.account-checks] - `account create` dispatch fn run_account(action: AccountAction, remote: &str) -> Result<(), String> { match action { AccountAction::Create { @@ -447,6 +459,7 @@ } } +// r[impl cli.account-checks] - `checks` dispatch fn run_checks(action: ChecksAction, remote: &str) -> Result<(), String> { match action { ChecksAction::List => list::<Check>(remote), @@ -492,6 +505,7 @@ Ok(()) } +// r[impl cli.toolchains] - dispatch fn run_toolchain(action: ToolchainAction, remote: &str) -> Result<(), String> { match action { ToolchainAction::Import { @@ -521,6 +535,7 @@ /// interactive terminal, unless `from` names a recipe (`registry::resolve`) /// to derive `bin`/`src`/`license`/`version`/`platform` from instead; /// explicit flags still win over a recipe's values. +// r[impl cli.toolchains] - `import` (including `--from`/`--embed`) #[expect(clippy::too_many_arguments, reason = "one flag per import field")] fn toolchain_import( name: Option<String>, @@ -607,6 +622,7 @@ Ok(()) } +// r[impl cli.toolchains] - `list` /// Print every toolchain configured on `remote` as /// `<name> <bin> <version> <platform> <license>`. fn toolchain_list(remote: &str) -> Result<(), String> { @@ -659,6 +675,8 @@ Ok(()) } +// r[impl cli.toolchains] - `export` +// r[impl cli.remote-admin] - `toolchain export` is read-only /// Export `remote`'s toolchain `name` to `dest`. Read-only per /// `cli.remote-admin`: fetches the toolchain's tree but never pushes. fn toolchain_export(name: &str, dest: &str, remote: &str) -> Result<(), String> { @@ -693,6 +711,7 @@ Ok(()) } +// r[impl cli.toolchains] - `remove` /// Remove toolchain `name` on `remote`, deleting its ref and pushing the /// update. fn toolchain_remove(name: &str, remote: &str) -> Result<(), String> { @@ -704,6 +723,7 @@ Ok(()) } +// r[impl cli.comments] - dispatch fn run_comment(action: CommentAction, remote: &str) -> Result<(), String> { match action { CommentAction::Add { @@ -723,6 +743,7 @@ /// record it at `refs/meta/comments/<id>` authored as the configured git /// identity, and push it. Prompts for the path and body left `None` when run /// at an interactive terminal. +// r[impl cli.comments] - `add` fn comment_add( path: Option<String>, body: Option<String>, @@ -753,6 +774,8 @@ Ok(()) } +// r[impl cli.comments] - `list` +// r[impl cli.remote-admin] - `comment list` is read-only /// List every comment on `remote` as `<id> <author> <location> <body>`, /// with each anchor projected onto `rev`. fn comment_list(remote: &str, rev: &str) -> Result<(), String> { @@ -774,6 +797,8 @@ Ok(()) } +// r[impl cli.comments] - `show` +// r[impl cli.remote-admin] - `comment show` is read-only /// Show the comment `id` (or a unique prefix): who wrote and last edited it, /// where it was anchored, where that sits on `rev`, the anchored text, and /// the body. @@ -827,6 +852,7 @@ Ok(()) } +// r[impl cli.comments] - `remove` /// Remove the comment `id` (or a unique prefix) on `remote`, deleting its ref /// and pushing the deletion. fn comment_remove(id: &str, remote: &str) -> Result<(), String> { @@ -971,6 +997,8 @@ } } +// r[impl cli.account-checks] - `checks list` +// r[impl cli.remote-admin] - a `Set` listing is read-only /// Print each entry of the set `S` on `remote` as `<key> <value>`. fn list<S: Set>(remote: &str) -> Result<(), String> { let repo = repo()?; @@ -986,6 +1014,7 @@ Ok(()) } +// r[impl cli.account-checks] - `checks remove` /// Drop the entry keyed `key` from the set `S` on `remote` and push the update. fn remove<S: Set>(key: &str, remote: &str) -> Result<(), String> { let repo = repo()?; @@ -1010,6 +1039,7 @@ /// unset when run at an interactive terminal. The whole set is validated as a /// dependency graph (`checks::order`) before it is stored, so a cycle or a /// dangling dependency never lands on the remote. +// r[impl cli.account-checks] - `checks add` fn add_check( name: Option<String>, command: Option<String>, @@ -1075,6 +1105,8 @@ /// (SSH-format signatures, the key, and "sign when the server asks" so pushes /// elsewhere are untouched). Writes global config by default, since the setup /// is per-machine. +// r[impl cli.members] - `setup` +// r[impl auth.client-setup] fn setup(key: Option<&Path>, local: bool) -> Result<(), String> { let scope = if local { "--local" } else { "--global" }; let signing_key = match key { @@ -1102,6 +1134,7 @@ /// Ensure a usable SSH key exists at `path`, returning the public-key path to /// record in `user.signingkey`. Generates an ed25519 keypair when neither the /// key nor its `.pub` is present; derives a missing `.pub` from the private key. +// r[impl cli.key-resolution] - `members setup` may generate a new `~/.ssh/id_ed25519` fn ensure_key(path: &Path) -> Result<String, String> { let (private, public) = key_paths(path); if public.exists() { @@ -1220,6 +1253,8 @@ /// `cert-authority` line per pinned-CA member — as /// `<username>[/<fingerprint>] <label><window>`, flagging keys on the /// `refs/meta/revoked` deny list as `[revoked]`. +// r[impl cli.members] - `list` +// r[impl cli.remote-admin] - `members list` is read-only fn members_list(remote: &str) -> Result<(), String> { let repo = repo()?; sync_namespace(remote, MEMBER_NS)?; @@ -1258,6 +1293,7 @@ /// Add `fingerprint` to `remote`'s `refs/meta/revoked` deny list and push the /// update, so the key is refused before its window would expire. +// r[impl cli.members] - `revoke` fn members_revoke(fingerprint: &str, remote: &str, reason: String) -> Result<(), String> { if !looks_like_fingerprint(fingerprint) { return Err(format!( @@ -1269,6 +1305,7 @@ // Revoking your own key fails closed against you too: if it is the last key // that authorizes your pushes, you cannot even push the un-revoke. Warn // before locking yourself out. + // r[impl cli.members] - confirmation before revoking the operator's own last key if own_fingerprint().is_some_and(|own| own == fingerprint) && !confirm(&format!( "{fingerprint} is your own signing key; \ @@ -1298,6 +1335,7 @@ /// Remove `fingerprint` from `remote`'s `refs/meta/revoked` deny list and push /// the update. +// r[impl cli.members] - `unrevoke` fn members_unrevoke(fingerprint: &str, remote: &str) -> Result<(), String> { let repo = repo()?; let expected = sync(remote, REVOKED_REF)?; @@ -1317,6 +1355,7 @@ /// either is already set or the terminal is non-interactive, so /// `--key`/`--cert-authority` and scripted runs are unchanged; otherwise /// prompts for which kind of trust to add. +// r[impl cli.interactive] - prompt for which kind of trust to add when unset at a TTY fn resolve_trust( key: Option<PathBuf>, cert_authority: Option<PathBuf>, @@ -1344,6 +1383,7 @@ clippy::too_many_arguments, reason = "each argument is an independent, optional member field set from its own CLI flag" )] +// r[impl cli.members] - `add` fn members_add( username: Option<String>, remote: &str, @@ -1431,6 +1471,7 @@ /// Revoke the member `username` on `remote`, deleting its ref and pushing the /// deletion. Removal here is a plain signed delete; quorum-gated removal is a /// later server-side policy. +// r[impl cli.members] - `remove` fn members_remove(username: &str, remote: &str) -> Result<(), String> { let refname = member_ref(username); let expected = @@ -1440,6 +1481,7 @@ Ok(()) } +// r[impl cli.account-checks] - `account create` /// Create or update this repository's account identity on `remote` and push it. fn account_create( username: Option<String>, @@ -1485,6 +1527,7 @@ /// signature back — the same proof the browser login page collects by hand. /// The returned session token is stored locally so `checks_debug` can reuse /// it. +// r[impl cli.login] - challenge fetch, sign, submit fn login(remote: &str, key: Option<&Path>) -> Result<(), String> { let (base, _repo_path) = remote_http_base(remote)?; let private_key = signing_key_file(key)?; @@ -1507,6 +1550,7 @@ /// Open an interactive, read-write shell in `remote`'s persistent checks /// Sprite, brokered by the server over a WebSocket using the session /// `login` stored. +// r[impl checks.debug] `git ents checks debug` fn checks_debug(remote: &str) -> Result<(), String> { let (base, repo_path) = remote_http_base(remote)?; let host = host_of(&base)?; @@ -1521,6 +1565,7 @@ /// The path to the private half of the signing key to use: `key` verbatim, or /// the path behind `user.signingkey`, resolved the same way `setup` does. +// r[impl cli.key-resolution] - default to `user.signingkey` fn signing_key_file(key: Option<&Path>) -> Result<PathBuf, String> { match key { Some(path) => Ok(key_paths(path).0), @@ -1535,6 +1580,7 @@ /// Sign `nonce` under [`LOGIN_NAMESPACE`] with the private key at `path`, /// returning the armored SSH signature. `ssh-keygen -Y sign` only writes a /// signature next to a file it read, so the nonce is staged there first. +// r[impl cli.login] - sign the fetched challenge with the configured key fn sign_challenge(private_key: &Path, nonce: &str) -> Result<String, String> { let dir = tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?; let data = dir.path().join("nonce"); @@ -1724,6 +1770,8 @@ } } +// r[impl cli.members] - `check` +// r[impl cli.remote-admin] - `members check` is read-only /// Report whether `key` is a member on `remote` and how this client is /// configured. fn check(remote: &str, key: Option<&Path>) -> Result<(), String> { @@ -1764,6 +1812,7 @@ /// the current value, returning the remote's current object id (or `None` when /// it has no such ref — for the signer set, the open bootstrap window). When the /// remote has none, clear any stale local ref so the set reads empty. +// r[impl cli.remote-admin] - fetch the relevant `refs/meta/*` ref before a mutating command edits it fn sync(remote: &str, refname: &str) -> Result<Option<String>, String> { let listing = ls_remote(remote, refname)?; let oid = listing.split_whitespace().next().map(str::to_owned); @@ -1779,6 +1828,7 @@ /// Mirror every ref under `remote`'s `namespace` (e.g. `refs/meta/member`) into /// the local repository, pruning local refs the remote no longer has, so the /// glob helpers see the remote's current set. +// r[impl cli.remote-admin] - fetch a whole `refs/meta/*` namespace for read-only listing commands fn sync_namespace(remote: &str, namespace: &str) -> Result<(), String> { let refspec = format!("+{namespace}/*:{namespace}/*"); git_run(&["fetch", "--quiet", "--prune", remote, &refspec]) @@ -1790,6 +1840,8 @@ /// not exist). Pushing with `--force-with-lease` pinned to that value, plus /// `--force-if-includes`, makes the update a clean compare-and-swap: it is /// rejected rather than clobbering a set someone changed since the fetch. +// r[impl cli.remote-admin] - signed push of an updated typed document back to the remote +// r[impl cli.compare-and-swap] - `--force-with-lease=<ref>:<expected>` plus `--force-if-includes`; create pins the lease to the zero object id fn push_signed(remote: &str, refname: &str, expected: Option<&str>) -> Result<(), String> { let lease = format!( "--force-with-lease={refname}:{}", @@ -1801,6 +1853,8 @@ /// Delete `refname` on `remote`, signed per the client's config and pinned with /// `--force-with-lease` to the `expected` tip so a member changed since the /// fetch is not clobbered. +// r[impl cli.remote-admin] - deletion is still a signed push +// r[impl cli.compare-and-swap] - `--force-with-lease` pinned to the observed tip fn push_delete(remote: &str, refname: &str, expected: &str) -> Result<(), String> { let lease = format!("--force-with-lease={refname}:{expected}"); let refspec = format!(":{refname}"); @@ -1809,6 +1863,7 @@ /// Resolve the OpenSSH public key to operate on, defaulting to the key behind /// `user.signingkey`. +// r[impl cli.key-resolution] - default to `user.signingkey`, accepting an inline `key::` value fn public_key(key: Option<&Path>) -> Result<String, String> { match key { Some(path) => read_public_key(path), @@ -1825,6 +1880,7 @@ /// Read an OpenSSH public key from `path`, accepting either a `.pub` file or a /// private key (whose public half is derived with `ssh-keygen -y`). +// r[impl cli.key-resolution] - a `.pub` file, or a private key whose public half is derived with `ssh-keygen -y` fn read_public_key(path: &Path) -> Result<String, String> { if let Ok(contents) = std::fs::read_to_string(path) && looks_like_public_key(&contents) @@ -1873,6 +1929,7 @@ /// The key's MD5 fingerprint in colon form (`aa:bb:…`). Colon-separated pairs /// are filesystem-safe, unlike the slashes in a base64 SHA256 fingerprint that /// would split the `members/<name>` entry into a subtree. +// r[impl cli.key-resolution] - MD5 colon-form fingerprint fn fingerprint(public_key: &str) -> Result<String, String> { let scratch = tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?;
crates/git-ents/src/registry.rs @@ -74,6 +74,7 @@ /// bytes; by default the recipe instead points at its distributor's own /// hosted, hash-verified archives (see [`Bin::Components`]), sparing the /// repository the toolchain's own bytes. +// r[impl cli.toolchains] - `import --from <recipe> --spec <name>` pub fn resolve(recipe: &str, spec: &str, embed: bool) -> Result<Resolved, String> { match recipe { "rustup" => rustup(spec, embed), @@ -128,6 +129,7 @@ /// `src` is `<sysroot>/lib/rustlib/src/rust`, unstaged, when the `rust-src` /// component is installed, else omitted, regardless of `embed`. Rust's own /// toolchain is dual-licensed `MIT OR Apache-2.0`. +// r[impl cli.toolchains] - `rustup` recipe: hosted archives by default, `--embed` for local bytes fn rustup(spec: &str, embed: bool) -> Result<Resolved, String> { let toolchain_arg = format!("+{spec}"); let sysroot = rustc(&toolchain_arg, &["--print", "sysroot"])?;
crates/git-store/src/lib.rs @@ -129,6 +129,8 @@ } } +// r[impl storage.meta-ref] +// r[impl nonfunctional.object-store] - object IO is opened on the common git directory, never a receive-pack quarantine /// A repository's typed `refs/meta/*` store. /// /// Refs are read and updated through the high-level [`gix`] API, while all @@ -142,6 +144,7 @@ } impl Store { + // r[impl nonfunctional.object-store] - opens the object database at `common_dir()/objects` explicitly /// Open the typed store for the repository at `repo`. pub fn open(repo: &Path) -> Result<Self, Error> { let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; @@ -165,6 +168,7 @@ /// this attempts a schema-aware structural merge of the two documents /// against their common base and retries, up to [`MAX_MERGE_RETRIES`] /// times, before giving up with [`Error::Conflict`]. + // r[impl storage.concurrency] - CAS write, retries a structural merge on conflict pub fn store<T: for<'a> Facet<'a>>( &self, refname: &str, @@ -188,6 +192,7 @@ self.store_impl(refname, value, message, Some(author)) } + // r[impl storage.concurrency] - CAS-and-merge retry loop fn store_impl<T: for<'a> Facet<'a>>( &self, refname: &str, @@ -229,6 +234,7 @@ /// machine advancing twice from the same state, so it fails closed with /// [`Error::Conflict`] rather than merging — merging stale state could /// resurrect a dead outcome. + // r[impl storage.concurrency] - in-place state advance fails closed on a race instead of merging pub fn amend<T: for<'a> Facet<'a>>( &self, refname: &str, @@ -935,6 +941,7 @@ tags: BTreeMap<String, String>, } + // r[verify storage.concurrency] - non-overlapping struct fields merge cleanly #[test] fn merge_disjoint_struct_fields_combine() { let dir = repo(); @@ -970,6 +977,7 @@ ); } + // r[verify storage.concurrency] - non-overlapping map entries merge cleanly #[test] fn merge_disjoint_map_entries_combine() { let dir = repo(); @@ -998,6 +1006,7 @@ assert_eq!(merged.tags, entries(&[("x", "1"), ("y", "2")])); } + // r[verify storage.concurrency] - a genuine conflict fails cleanly instead of picking a winner #[test] fn merge_same_scalar_changed_both_ways_conflicts() { let dir = repo(); @@ -1073,6 +1082,7 @@ assert!(matches!(result, Err(Error::Conflict))); } + // r[verify storage.concurrency] - CAS detects a moved ref #[test] fn try_set_ref_conflicts_on_a_stale_expected() { let dir = repo(); @@ -1095,6 +1105,7 @@ assert!(matches!(result, Err(Error::Conflict))); } + // r[verify storage.concurrency] - no common ancestor cannot be merged #[test] fn store_conflicts_on_a_fresh_ref_race_with_no_common_base() { let dir = repo(); @@ -1114,6 +1125,7 @@ assert!(matches!(result, Err(Error::Conflict))); } + // r[verify storage.concurrency] - a state advance fails closed rather than merging #[test] fn amend_fails_closed_on_a_race_instead_of_merging() { let dir = repo();
crates/git-ents-server/src/web/debug.rs @@ -30,6 +30,7 @@ /// Upgrade an authenticated member's request into an interactive shell in /// `repo_path`'s checks Sprite. +// r[impl checks.debug] brokered over WebSocket at /_debug/<repo>; refuses without a signed-in session pub(crate) async fn handshake( State(state): State<AppState>, Path(repo_path): Path<String>, @@ -90,6 +91,7 @@ /// pseudo-TTY, but the broker allocates its *own* local pty for the `sprite /// exec --tty` process so a resize control frame (see below) has something to /// apply to — plain pipes have no window size to change. +// r[impl checks.debug] interactive read-write shell in the Sprite; server holds only the SPRITES_TOKEN credential async fn relay(mut socket: WebSocket, sprite: String) { let pair = match native_pty_system().openpty(INITIAL_SIZE) { Ok(pair) => pair, @@ -175,6 +177,7 @@ /// Parse a resize control frame, `"<cols> <rows>"`, as sent by the CLI on /// connect and on every local `SIGWINCH`. +// r[impl checks.debug] terminal resize forwarded as control frames fn parse_resize(text: &str) -> Option<PtySize> { let (cols, rows) = text.split_once(' ')?; Some(PtySize {
crates/git-ents-server/src/web/git.rs @@ -46,6 +46,7 @@ /// bounds the memory a single request can consume, so an arbitrarily large blob /// or diff renders as a truncation notice instead of being slurped whole — the /// difference between a capped response and an out-of-memory kill. +// r[impl nonfunctional.memory-cap] - kills git and truncates once the cap is exceeded pub(super) async fn git_output_capped( repo: &Path, args: &[&str], @@ -345,6 +346,7 @@ } } + // r[verify nonfunctional.memory-cap] #[tokio::test] async fn capped_read_flags_oversized_output() { let dir = tempfile::tempdir().unwrap(); @@ -357,6 +359,7 @@ assert_eq!(bytes.len(), 1024); } + // r[verify nonfunctional.memory-cap] #[tokio::test] async fn capped_read_returns_full_small_output() { let dir = tempfile::tempdir().unwrap();
crates/git-ents-server/src/web/mod.rs @@ -50,6 +50,8 @@ /// Render the page for `path`: the repository index at the root, a repository /// overview, or one of its browse views (`tree`, `blob`, `commit`). `host` is /// the request's `Host` header, used to build a copy-pasteable clone URL. +// r[impl web.server-rendered] - entry point for every browser GET +// r[impl web.auth.challenge] `/login` and `/login/cli` issue the one-time challenge pub(crate) async fn render( state: &AppState, path: &str, @@ -135,6 +137,8 @@ /// Handle a browser POST: signing in, signing out, or saving a settings edit. /// Git wire POSTs never reach here — [`crate::http`] routes those to the backend. +// r[impl web.auth.challenge] the `/login` and `/login/cli` POST endpoints complete the challenge-response +// r[impl web.auth.session] `/login` opens the session cookie; `/logout` requires the CSRF token and clears it pub(crate) async fn handle_post( state: &AppState, path: &str, @@ -190,6 +194,7 @@ /// gate (nonce seed + hooks) and its own signing key, all of which /// [`write::edit_config`] requires. When any is unset, edit controls are not /// offered so a member is never told they can edit when a submit would only fail. +// r[impl web.auth.edit] hides edit controls when the server has no signing key or nonce seed fn editing_enabled(state: &AppState) -> bool { state.cert_nonce_seed.is_some() && state.hooks_dir.is_some() && state.web_signing_key.is_some() } @@ -206,6 +211,7 @@ /// Apply a settings edit, then redirect back to the settings page on success or /// render the reason it was rejected. +// r[impl web.auth.edit] the settings-edit request path (CSRF check, then a real signed push) async fn save_settings( state: &AppState, repo: &Path, @@ -273,6 +279,7 @@ /// Record a code comment posted from a file view, then redirect back to that /// file on success or render the reason it was rejected. +// r[impl web.comments] - lands a browser comment through the same signed-push gate as a settings edit async fn save_comment( state: &AppState, repo: &Path, @@ -358,6 +365,7 @@ } /// The `Set-Cookie` value that opens a session, marked `Secure` over HTTPS. +// r[impl web.auth.session] issues the `ents_session` cookie fn session_cookie(token: &str, secure: bool) -> String { format!( "{}={token}; Path=/; HttpOnly; SameSite=Lax{}", @@ -367,6 +375,7 @@ } /// The `Set-Cookie` value that clears a session. +// r[impl web.auth.session] clears the cookie on sign-out fn cleared_cookie(secure: bool) -> String { format!( "{}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}", @@ -378,6 +387,8 @@ /// Dispatch the part of the path that follows the repository to a browse view. /// Each top-level tab is its own route, since the product is server-rendered /// with no client JavaScript. +// r[impl web.server-rendered] - navigation is ordinary GET requests, dispatched here +// r[impl web.tabs] - routes each tab to its page renderer async fn route( repo: &Path, rel: &str, @@ -460,6 +471,7 @@ } /// The top-level tabs of a repository page. +// r[impl web.tabs] - the set of tabs a repository's web UI provides #[derive(Clone, Copy, PartialEq, Eq)] enum Tab { Overview, @@ -589,6 +601,7 @@ /// The tab bar with the active tab underlined. Tabs that have no backing data /// yet still render so the navigation matches the design. +// r[impl web.tabs] - the tab bar navigation fn tab_bar(meta: &RepoMeta, active: Tab) -> Markup { let rel = &meta.rel; html! { @@ -610,6 +623,7 @@ } /// The repository listing shown at `/`. +// r[impl web.index] fn index(state: &AppState, session: Option<&write::SessionSnapshot>) -> Markup { let repos = discover_repos(&state.data_dir); page( @@ -685,6 +699,7 @@ /// The sign-in page: prove control of a member key by signing a one-time /// challenge locally, without ever surrendering the key. `error` shows a failed /// attempt's reason; `challenge` is the nonce to sign. +// r[impl web.auth.challenge] points at `git ents login` as the preferred flow, manual signing as fallback fn login_page( session: Option<&write::SessionSnapshot>, challenge: Option<&str>, @@ -749,6 +764,8 @@ } /// Wrap page `body` in the shared HTML shell, navigation, and styling. +// r[impl web.server-rendered] - the shared shell; its two scripts (copy-to-clipboard, +// live check polling) are progressive enhancement, not required for navigation fn page(title: &str, body: Markup) -> Markup { html! { (DOCTYPE)
crates/git-ents-server/src/web/pages.rs @@ -31,6 +31,8 @@ /// notice instead — a cap on what one page can cost. 2 MiB comfortably covers /// real source files while ruling out the multi-hundred-MiB objects that would /// exhaust the server. +// r[impl web.syntax-highlight] - the 2 MiB truncation cap on blob views +// r[impl nonfunctional.memory-cap] - no single request renders more than 2 MiB of object data const MAX_RENDER_BYTES: usize = 2 * 1024 * 1024; /// Render an Askama tab-body template into [`Markup`] the Maud page shell can @@ -270,6 +272,7 @@ /// client JavaScript, expanding a folder or opening a file is a link to /// `/<repo>/files/<path>`; the tree is rendered already expanded along the /// selected path. +// r[impl web.tabs] - Files tab: browsable file tree with blob/comments pane pub(super) async fn files_page( repo: &Path, meta: &RepoMeta, @@ -523,6 +526,8 @@ /// [`BlobView::Source`], with a toggle between the two; everything else is /// syntax-highlighted source when the language is recognized and the file is /// text. +// r[impl web.tabs] - Files tab: blob view +// r[impl web.syntax-highlight] - toggle between rendered document and highlighted source pub(super) async fn blob_page( repo: &Path, meta: &RepoMeta, @@ -595,6 +600,7 @@ /// Render text file `source` with a line-number gutter — each number a /// self-linking `#L<n>` anchor — highlighting via `arborium` when the filename /// maps to a known grammar. +// r[impl web.syntax-highlight] - self-linking `#L<n>` gutter anchors fn blob_body(name: &str, source: &str) -> Markup { let lines = source.lines().count().max(1); let highlighted = highlight(name, source); @@ -619,6 +625,7 @@ /// (in which case the caller renders escaped plain text). The highlighter is /// built and used synchronously so its non-`Send` grammar store is never held /// across an `.await`. +// r[impl web.syntax-highlight] - compile-time language registry (`arborium`) fn highlight(name: &str, source: &str) -> Option<String> { let language = arborium::detect_language(name)?; let config = Config { @@ -652,6 +659,7 @@ /// async runtime since git-comment reads the object database synchronously. /// Comments that fail to project (say, an anchor commit the repository no /// longer has) are skipped rather than failing the page. +// r[impl web.comments] - projects comment anchors onto the viewed revision, flagging outdated ones async fn file_comments(repo: &Path, path: &str) -> Vec<FileComment> { let repo = repo.to_owned(); let path = path.to_owned(); @@ -695,6 +703,7 @@ /// when the viewer may comment; nothing when there are neither. A /// line-anchored comment links its range to the gutter's `#L<n>` anchors; an /// outdated one is flagged instead, since its lines no longer exist. +// r[impl web.comments] - lists a file's anchored comments under its blob view fn comments_card(comments: &[FileComment], form: Option<Markup>) -> Markup { if comments.is_empty() && form.is_none() { return html! {}; @@ -726,6 +735,7 @@ /// The add-comment form under a file view, shown when a signed-in member views /// a server that can land edits; `None` otherwise, since a submit would only /// fail. The comment anchors to `HEAD`'s blob at `path`. +// r[impl web.comments] - a signed-in member can add a comment from the file view fn comment_form( rel: &str, path: &str, @@ -749,6 +759,7 @@ } /// A single commit: its metadata and a colorized unified diff. +// r[impl web.tabs] - Commits: commit history with diff views pub(super) async fn commit_page(repo: &Path, meta: &RepoMeta, sha: &str) -> Response { if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { return not_found().into_response(); @@ -807,6 +818,7 @@ } /// The Releases tab: tags presented as a changelog timeline, newest first. +// r[impl web.tabs] - Releases: git tags browsable by version pub(super) async fn releases_page(repo: &Path, meta: &RepoMeta) -> Markup { let releases = releases(repo).await; repo_shell( @@ -855,6 +867,7 @@ /// check, its latest status against the current commit, linked to its recorded /// terminal session when it has one; Recent runs and Configuration below it are /// the full history and the raw set, as before. +// r[impl web.tabs] - Checks: configured check set and recorded runs with per-check outcomes pub(super) async fn checks_page(repo: &Path, meta: &RepoMeta) -> Markup { let rel = &meta.rel; let checks = component::load::<git_ents_core::checks::Check>(repo).await; @@ -1103,6 +1116,7 @@ /// `refs/meta/issues/<id>`, split into open and closed, with the filter chips /// derived from the labels that exist. Issue creation is a write path that does /// not exist yet, so the "New issue" button stays disabled. +// r[impl web.tabs] - Issues: issue list with open/closed filter and per-issue detail pub(super) async fn issues_page(repo: &Path, meta: &RepoMeta) -> Markup { let tpl = match component::load::<git_ents_core::issues::Issue>(repo).await { Err(err) => IssuesTemplate { @@ -1156,6 +1170,7 @@ /// feature and check status. The General fields are editable in place by a /// signed-in member when `editing` is set (the server has a signing key and the /// gate); everything else is read-only. +// r[impl web.tabs] - Settings: repository Config fields, editable by signed-in members pub(super) async fn settings_page( repo: &Path, meta: &RepoMeta,
crates/git-ents-server/src/web/write.rs @@ -39,6 +39,7 @@ /// One browser session. It holds only the member's *public* key — enough to /// authorize per repository — plus a display label and a CSRF token. +// r[impl web.auth.session] holds only the public key, label, and CSRF token; nothing secret, in memory only pub(crate) struct Session { /// The member's public key line (`type base64`), matched against members. public_key: String, @@ -82,6 +83,7 @@ } /// Issue a fresh one-time sign-in challenge, returning the nonce to sign. +// r[impl web.auth.challenge] server issues a one-time nonce pub(super) fn issue_challenge(challenges: &Challenges) -> Result<String, String> { let nonce = random_token()?; let mut table = challenges @@ -94,6 +96,7 @@ } /// Consume `nonce`, returning whether it was a live, unexpired challenge. +// r[impl web.auth.challenge] a challenge is consumed on first use and expires after CHALLENGE_TTL (600s) fn take_challenge(challenges: &Challenges, nonce: &str) -> bool { let Ok(mut table) = challenges.lock() else { return false; @@ -120,6 +123,8 @@ /// against the pasted `public_key`, and on success open a session and return its /// token. Holding a session grants nothing on its own — an edit is authorized /// per repository against the live member list. +// r[impl web.auth.challenge] verifies the pasted signature against the pasted public key +// r[impl web.auth.session] on success, opens a session carrying an unguessable random token pub(super) fn login( sessions: &Sessions, challenges: &Challenges, @@ -157,6 +162,7 @@ } /// Whether `cookie`'s session exists and its CSRF token matches `token`. +// r[impl web.auth.session] state-changing POSTs must echo back the session's CSRF token pub(super) fn csrf_ok(sessions: &Sessions, cookie: Option<&str>, token: &str) -> bool { let Some(session_token) = cookie.and_then(self::token) else { return false; @@ -169,6 +175,7 @@ } /// Drop the session a `Cookie` header points at, if any. +// r[impl web.auth.session] sign-out drops the session from server memory pub(super) fn logout(sessions: &Sessions, cookie: Option<&str>) { let Some(token) = cookie.and_then(token) else { return; @@ -184,6 +191,7 @@ /// `pre_receive` cannot enforce this — it is purely key-based, and a /// self-attested member typically has no push key to gate — so the web write /// path is the enforcement point. +// r[impl web.auth.edit] gates settings edits to admin-registered members 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) @@ -206,6 +214,7 @@ /// `seed`, `hooks`, and `signing_key` are the server's signed-push nonce seed, /// hooks directory, and own member key; all are required, so a web edit is never /// a way around a server that is not enforcing the gate. +// r[impl web.auth.edit] lands the settings edit as a signed push through the pre-receive gate pub(super) fn edit_config( sessions: &Sessions, cookie: Option<&str>, @@ -245,6 +254,7 @@ /// blob at the commented path. Any signed-in member may comment — including a /// self-attested web member, whose allowed writes are exactly issues and /// comments — so there is no [`require_admin_registered`] gate here. +// r[impl web.comments] - signed push through the same pre-receive gate, no admin-registered provenance required pub(super) fn add_comment( sessions: &Sessions, cookie: Option<&str>, @@ -319,6 +329,7 @@ /// *always* deleted before returning — whether or not the push was accepted, /// so a rejected edit never leaves a zombie ref behind — and the commit that /// lands is authored by `username` while the server is the committer. +// r[impl web.auth.edit] author is the signed-in member, committer is the server; staging ref always deleted #[expect( clippy::too_many_arguments, reason = "the server identity a signed edit requires" @@ -353,6 +364,7 @@ /// Point `staging` at `target_ref`'s current tip, build the new commit on it /// authored by `username`, then push it signed with the server's key onto /// `target_ref`. +// r[impl web.auth.edit] the actual `git push --signed` with the server's own key #[expect(clippy::too_many_arguments, reason = "internal step of signed_edit")] fn stage_and_push<T: for<'a> facet::Facet<'a>>( repo: &Path, @@ -417,6 +429,7 @@ /// Verify an SSHSIG `signature` over `nonce` was made by `public_key` under the /// login namespace, using `ssh-keygen -Y verify` against a one-key allowed /// signers file. +// r[impl web.auth.challenge] SSHSIG verification under the distinct git.ents.cloud namespace fn verify_login_signature(public_key: &str, nonce: &str, signature: &str) -> Result<bool, String> { let dir = tempfile::tempdir().map_err(|e| format!("could not create temp dir: {e}"))?; let allowed = dir.path().join("allowed_signers");
Diff truncated (over 1 MiB).