git-ents.gitmain
⌘K
foforge
commit 524e423
feat: wire up tracey requirement tracking against docs/spec

Registers a tracey config so the specification’s coverage against the Rust implementation can be tracked going forward. The single-file spec is split into one file per topic so tracey’s uncovered/status views group requirements by section instead of dumping a flat list.

feat: add .config/tracey/config.styx tracking docs/spec//*.adoc against crates//*.rs docs: split docs/specification.adoc into per-topic files under docs/spec/ 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

.config/tracey/config.styx @@ -1,0 +1,12 @@ +specs ( + { + name git-ents + include (docs/spec/**/*.adoc) + impls ( + { + name rust + include (crates/**/*.rs) + } + ) + } +)
docs/spec/account.adoc @@ -1,0 +1,23 @@ +== Account + +[role="requirement", id="account.ref"] +.Account Profile +-- +A repository becomes an account repository by carrying a `refs/meta/account` +ref. +The `Account` document at that ref MUST hold: `username`, `display_name`, +`bio`, and `created_at` (seconds since the Unix epoch). +The username is authoritative in the document; the repository path is +convention, not trust. +-- + +[role="requirement", id="account.genesis"] +.Account Identity +-- +An account's stable, path-independent identity MUST be the content hash of +the first `Account` document ever recorded on its ref — fixed at creation, +so later profile edits never change it and a reference to the account +(<<members.account>>) survives the account repository moving. +The identity MUST be derived from the ref's history, never stored as a +field. +--
docs/spec/auth.adoc @@ -1,0 +1,50 @@ +== Push Authentication + +[role="requirement", id="auth.bootstrap"] +.Bootstrap Window +-- +When no `refs/meta/member/*` refs exist the repository MUST accept any push +without a signature, so the first member can be pushed in. +Once at least one member ref exists the bootstrap window MUST close and all +subsequent pushes MUST be signed. +Revoking every member's keys — leaving the member refs present but all keys +removed — MUST fail closed, not reopen the bootstrap window. +-- + +[role="requirement", id="auth.signed-push"] +.Signed Push Verification +-- +Every push to a repository whose member list is non-empty MUST carry a signed +push certificate (`git push --signed`). +The server MUST verify the certificate in a `pre-receive` hook before any ref +is updated: + +. The push certificate nonce status (from `GIT_PUSH_CERT_NONCE_STATUS`) MUST + be `OK`. +. The certificate MUST contain an SSH signature (an + `-----BEGIN SSH SIGNATURE-----` block). +. The signature MUST verify against at least one authorized member key via + `ssh-keygen -Y verify -n git`. +. The trust set fed to `ssh-keygen` MUST be the live member set minus any + revoked fingerprints. + +A push that fails any check MUST be rejected before any ref is updated. +-- + +[role="requirement", id="auth.nonce"] +.Nonce Configuration +-- +The server MUST configure `receive.certNonceSeed` on every `git http-backend` +invocation when authentication is active. +The server MUST configure `receive.certNonceSlop = 60` to tolerate the +round-trip latency inherent in smart-HTTP, where the nonce is issued and +verified by two separate `receive-pack` processes. +-- + +[role="requirement", id="auth.client-setup"] +.Client-Side Setup +-- +A CLI command (`git ents members setup`) MUST configure the client's local or +global git config to sign pushes with an SSH key, using the key named by +`user.signingkey` or a default `~/.ssh/id_ed25519`. +--
docs/spec/checks.adoc @@ -1,0 +1,100 @@ +== Checks + +[role="requirement", id="checks.definition"] +.Check Set Definition +-- +The configured checks for a repository MUST be stored at `refs/meta/checks` as +a map from check name to a check body holding an optional shell command, an +optional sandbox image, and an optional list of dependencies (names of sibling +checks that must pass first). +A check with no command is a composite: it runs nothing itself and derives its +outcome from its dependencies alone. +A check's definition living on a meta ref means a branch under check cannot +rewrite the check set that gates it. + +The dependency graph is fully static — no conditional edges, no runtime +expansion — and MUST be validated when the set is written: a dependency naming +no configured check, a duplicate or self edge, a check with neither a command +nor dependencies, and any dependency cycle MUST each be rejected before the +set is stored. A check that sets an image MUST be rejected until the sandbox +can honor one, rather than the image being silently ignored; the field is +reserved in the format so honoring it later is not a data migration. +-- + +[role="requirement", id="checks.post-receive"] +.Asynchronous Queuing +-- +After a successful push is accepted by `pre-receive`, a `post-receive` hook +MUST enqueue a check job for each updated branch whose new tip is non-zero and +is not a `refs/meta/*` ref. +The hook MUST record an initial run status of `queued` for each check +immediately, so the UI reflects the job before the worker picks it up. +The hook MUST NOT run checks itself; it MUST return as soon as jobs are +enqueued, so the push is never blocked on check execution. +Job files MUST be written to a tmp path and renamed into place so the worker +never observes a partial file. +-- + +[role="requirement", id="checks.worker"] +.Check Worker +-- +A persistent server-side worker MUST drain the job queue. +Jobs MUST be grouped by repository; each repository's jobs MUST be processed +serially to prevent concurrent runs from colliding in its sandbox. +Separate repositories MUST be processed concurrently, so a slow repository's +backlog does not block others. + +Within a job the checks MUST settle in topological dependency order, so every +dependency's outcome is terminal before its dependents are reached. +A check whose dependency did not pass MUST be recorded `skipped` without +running; a composite's outcome is derived from its dependencies — `pass` when +all passed, `fail` when any failed or errored, `skipped` otherwise. +The worker MUST re-validate the dependency graph before running (a hand-crafted +push can land a set the CLI would have rejected) and finalize the run as +`error` when it is invalid. +-- + +[role="requirement", id="checks.sandbox"] +.Sprite Sandbox +-- +Each check MUST run inside a Fly.io Sprite: a persistent, hardware-isolated +sandbox, one per repository, so build caches survive between pushes. +Before running checks the worker MUST sync the pushed tree into the Sprite's +work directory via `git archive` piped to `tar -x`. +The worker MUST configure the `sprite` CLI from `SPRITES_TOKEN` before each +run via `sprite auth setup`, so the credential stays current without restart. +-- + +[role="requirement", id="checks.outcomes"] +.Run Recording +-- +Run outcomes MUST be stored at `refs/meta/runs/<commit>`, one ref per checked +commit. +Each ref's commit chain is the run history; each commit's date is the run time, +so no timestamp is duplicated in the document tree. +Outcome values progress: `queued` → `running` → `pass` / `fail` / `error` / +`skipped` (a check whose dependency did not pass, per <<checks.worker>>). +A run that cannot complete due to an infrastructure error (unreachable Sprite, +failed sync) MUST be finalized as `error` rather than left stuck at `running`. +A single check that exceeds a 30-minute timeout MUST be recorded `error` rather +than blocking the worker thread indefinitely. +A check definition and a run outcome MAY each carry additional metadata beyond +a single command/outcome string (for example a run's duration and log URL). +Optional fields absent from an older record MUST load as unset. +A check/outcome name is the map key and MUST NOT be stored redundantly inside +the record. +-- + +[role="requirement", id="checks.debug"] +.Sprite Debug Shell +-- +A signed-in member MUST be able to open an interactive, read-write shell in +a repository's checks Sprite (`git ents checks debug`), brokered by the +server over a WebSocket at the reserved path `/_debug/<repo>`. +The server holds the only Fly credential (`SPRITES_TOKEN`); a member MUST +NOT need one of their own. +The broker MUST refuse the connection without a signed-in session. +Local terminal resizes MUST be forwarded to the remote pseudo-terminal as +control frames distinct from the byte stream, so the remote shell tracks the +member's window size. +--
docs/spec/cli.adoc @@ -1,0 +1,106 @@ +== Client CLI + +[role="requirement", id="cli.remote-admin"] +.Remote Administration Over Git +-- +The `git-ents` CLI MUST administer a remote's trust and metadata refs as +ordinary signed git pushes, with no separate admin API or server endpoint. +A mutating command MUST fetch the relevant `refs/meta/*` ref(s) from the named +remote (defaulting to `origin`) into the local clone, load the typed document, +apply the change, store it, and push the updated ref back — signed per the +client's git config, through the same `pre-receive` gate a content push +traverses. +A read-only command (`members list`, `members check`, `checks list`, +`comment list`/`show`) MUST only fetch, never push. +-- + +[role="requirement", id="cli.interactive"] +.Interactive Prompting +-- +A mutating command run at an interactive terminal MUST prompt for any +required field left unset rather than fail. +The same invocation without a TTY MUST fail with an error naming the missing +field; it MUST NOT hang waiting for input. +-- + +[role="requirement", id="cli.compare-and-swap"] +.Optimistic Concurrency +-- +Every CLI push MUST be a compare-and-swap pinned to the ref tip observed at +fetch time, expressed as `--force-with-lease=<ref>:<expected>` together with +`--force-if-includes`, so a change made on the remote since the fetch is +rejected rather than clobbered; a create pins the lease to the zero object id. +This is the client-side counterpart to <<storage.concurrency>>. +-- + +[role="requirement", id="cli.members"] +.Member Commands +-- +The CLI MUST provide, under `git ents members`: + +* `list` — render the live member set, each key with its fingerprint, validity + window, and a flag when it appears on the revocation list. +* `add` — authorize a leaf key or pin a certificate authority for a username, + with optional `valid-after` / `valid-before` window; adding a member MUST + create the ref when absent. +* `remove` — delete the member's ref. +* `revoke` / `unrevoke` — add or remove a fingerprint on `refs/meta/revoked`. +* `check` — report whether a key is a member of the remote and echo the + client's signing config (`gpg.format`, `user.signingkey`, `push.gpgSign`). +* `setup` — configure signed pushes (see <<auth.client-setup>>). + +Revoking the operator's own last authorizing key MUST prompt for confirmation +before proceeding, since it can lock the operator out of the remote. +-- + +[role="requirement", id="cli.account-checks"] +.Account and Check Commands +-- +`git ents account create` MUST write or update `refs/meta/account` +(<<account.ref>>) and print the account's genesis identity +(<<account.genesis>>). +`git ents checks` MUST provide `list`, `add`, and `remove` over the +`refs/meta/checks` set (<<checks.definition>>). +-- + +[role="requirement", id="cli.comments"] +.Comment Commands +-- +The CLI MUST provide, under `git ents comment`: + +* `add` — anchor a comment to a path, an optional 1-based inclusive line + range, and a revision (defaulting to `HEAD`), optionally attached to an + issue by its genesis id, and push it. +* `list` / `show` — project each comment's anchor onto a chosen revision and + report its projection state (<<comments.projection>>); `show` MUST print + the anchored text derived from the blob, and both MUST report the author + recovered from the ref's commits (<<comments.authorship>>). +* `remove` — delete the comment's ref. + +Comment commands MUST follow the same remote-administration flow as member +commands (<<cli.remote-admin>>, <<cli.compare-and-swap>>). +-- + +[role="requirement", id="cli.login"] +.CLI Sign-In +-- +`git ents login` MUST complete the same challenge–response sign-in the +browser uses (<<web.auth.challenge>>) with no manual copy-and-paste: the CLI +fetches a challenge from the remote, signs it with the client's configured +signing key, submits the proof, and receives the same session a browser +sign-in yields. +The server MUST expose the challenge and verify steps as plain-text +endpoints suitable for non-browser clients. +-- + +[role="requirement", id="cli.key-resolution"] +.Key Resolution and Fingerprints +-- +The key a command operates on MUST default to `user.signingkey`, accepting an +inline `key::` value, a `.pub` file, or a private key whose public half is +derived with `ssh-keygen -y`; `members setup` MAY generate a new +`~/.ssh/id_ed25519` when none exists. +A key's fingerprint MUST be the MD5 colon form, whose separators are safe as a +ref-path/tree-entry segment, unlike the slashes a base64 SHA256 fingerprint +would introduce. +--
docs/spec/comments.adoc @@ -1,0 +1,52 @@ +== Comments + +[role="requirement", id="comments.ref"] +.Comment Documents +-- +Each code comment MUST be stored at `refs/meta/comments/<id>` as a `Comment` +document with fields: `body`, `anchor` (<<comments.anchor>>), and an +optional `issue` cross-referencing an issue by its genesis id +(<<issues.id>>); an absent `issue` means a free-standing comment. +The identifier MUST follow the same genesis-key rule as an issue: the object +id of the originating object, or of the comment's own initial content, and +it is never renamed. +One ref per comment keeps comments independently loadable and separately +historied; the ref's commit chain is the comment's edit history. +-- + +[role="requirement", id="comments.authorship"] +.Authorship From the Commit Chain +-- +A comment's author and timestamps MUST NOT be stored in the document tree. +The creator is the author of the ref's first commit and the last editor is +the author of its tip commit; both MUST be recovered from the commit chain +at read time. +-- + +[role="requirement", id="comments.anchor"] +.Anchors +-- +A comment MUST be anchored to the exact content it was written against: the +commit, the repository-relative file path, the file's blob object id, and an +optional 1-based inclusive line range (absent for a whole-file comment). +An anchor is authoritative at creation and MUST NEVER be mutated afterwards. +Creating an anchor MUST validate the path and line range against the +revision's actual content. +The anchored text is fully derivable from the blob and the line range and +MUST be derived at read time, never stored redundantly. +-- + +[role="requirement", id="comments.projection"] +.Anchor Projection +-- +Displaying a comment against any revision other than its own MUST be a +read-time projection of its anchor, reporting one of four outcomes: +*current* (the exact blob still sits at the anchored path), *relocated* (the +file moved and/or the range shifted past edits outside it, reported with the +new path and range), *outdated* (an edit touched the anchored region, or the +entry is no longer a regular file), or *deleted* (the file is gone). +Projection MUST follow renames and MUST work between any two commits — +forwards, backwards, or across unrelated history. +An outdated or deleted projection MUST NOT lose the comment: the original +anchor remains displayable. +--
docs/spec/compat.adoc @@ -1,0 +1,65 @@ +== Compatibility + +[role="requirement", id="compat.git"] +.Git Tooling +-- +The server MUST invoke `git` (including `git http-backend`, `git receive-pack`, +`git archive`, `git cat-file`, `git for-each-ref`, `git symbolic-ref`, and +`git init --bare`) as external subprocesses. +Git MUST be present on `PATH` at runtime. +Git config overrides MUST be passed via `GIT_CONFIG_COUNT` / +`GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` environment variables rather than +`git -c`, so they reach the `receive-pack` and `pre-receive` processes that +`git http-backend` spawns, not merely the CGI wrapper process. +-- + +[role="requirement", id="compat.ssh-keygen"] +.OpenSSH `ssh-keygen` +-- +Push certificate verification and browser sign-in verification MUST use +`ssh-keygen -Y verify` against an `allowed_signers` file written at runtime. +`ssh-keygen` (OpenSSH) MUST be present on `PATH` at runtime. +The `allowed_signers` format used MUST be the OpenSSH format: one line per key, +with a principal column, optional comma-joined options (`cert-authority`, +`valid-after`, `valid-before`, `namespaces`), and the key material. +-- + +[role="requirement", id="compat.openssh-signed-push"] +.Signed Push Protocol +-- +Push signatures MUST use OpenSSH (`gpg.format = ssh`) push certificates. +The server MUST read the pushed certificate's object ID from `GIT_PUSH_CERT` +and its nonce status from `GIT_PUSH_CERT_NONCE_STATUS` — the environment +variables git populates for the `pre-receive` hook — and MUST NOT assume any +other delivery mechanism. +-- + +[role="requirement", id="compat.sprite"] +.Sprite CLI +-- +Asynchronous checks MUST be executed via the `sprite` CLI +(`sprite auth setup`, `sprite create`, `sprite exec`). +The `sprite` binary MUST be present on `PATH` at runtime when checks are +configured. +The `sprite` CLI MUST be initialized per-push via `sprite auth setup --token` +from the `SPRITES_TOKEN` environment variable, since the CLI persists +credentials to a config file and does not read the token per invocation. +-- + +[role="requirement", id="compat.cgi"] +.CGI Protocol +-- +`git http-backend` is a CGI program. +The server MUST populate the standard CGI environment variables (`PATH_INFO`, +`QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE`, `CONTENT_LENGTH`, +`GIT_PROJECT_ROOT`, `GIT_HTTP_EXPORT_ALL`) before spawning it. +The server MUST parse the CGI response format (header block, `\r\n\r\n` +separator, body) and translate it into an HTTP response. +-- + +[role="requirement", id="compat.edition"] +.Rust Edition +-- +All crates MUST target Rust edition 2024. +The workspace MUST NOT be published to crates.io (`publish = false`). +--
docs/spec/config.adoc @@ -1,0 +1,11 @@ +== Repository Configuration + +[role="requirement", id="config.ref"] +.Configuration Ref +-- +A repository's loose metadata MUST be stored at `refs/meta/config` as a +`Config` document with fields: `description`, `homepage`, and `topics`. +Keeping metadata on a meta ref means a push of ordinary content cannot rewrite +it, and the metadata carries its own independent history. +An absent ref MUST yield the default (all empty / zero). +--
docs/spec/conformance.adoc @@ -1,0 +1,79 @@ += Conformance + +A map from each requirement to the module or function implementing it and the +test guarding it, so a requirement with no entry (or an entry marked +_Planned_) is immediately visible as unimplemented rather than discovered by +reading every module. Update this table in the same change that adds, +renames, or removes a requirement or its implementation. + +[cols="1,2,2", options="header"] +|=== +|Requirement |Implementation |Guarding test + +|`storage.bare` |`git-ents-server/src/http.rs` (`backend`, repo auto-init) |`server::push_then_clone_round_trip` +|`storage.meta-ref` |`git-store/src/lib.rs` (`Store::load`/`store`) |per-document `loads_the_on_disk_*_format` fixture tests +|`storage.concurrency` |`git-store/src/merge.rs` (`three_way_merge`), `Store::store_impl`/`amend` |`git-store::tests::merge_*`, `amend_fails_closed_on_a_race_instead_of_merging` +|`protocol.git` |`git-ents-server/src/http.rs` (`backend`) |`server::push_then_clone_round_trip` +|`protocol.routing` |`git-ents-server/src/http.rs` (`get_request`/`post_request`, `detects_pushes`) |`http::tests::detects_pushes`, `routes_browser_gets` +|`namespace.url` |`README.adoc`/deployment config | _(documentation only)_ +|`namespace.path` |`git-ents-server/src/http.rs` (segment validation) |`http::tests::validates_segments`, `extracts_repo_path` +|`namespace.auto-create` |`git-ents-server/src/http.rs` (`backend` init lock, `reconcile_head`) |`server::rejects_colliding_pushes` +|`members.ref` |`git-ents/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref` +|`members.trust` |`git-ents/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}` +|`members.provenance` |`git-ents/src/members.rs` (`Provenance`); `git-ents-server/src/web/write.rs` (`require_admin_registered`) |`members::loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered`, `web_edit::a_self_attested_member_is_refused_a_settings_edit` +|`members.account` |`git-ents/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member` +|`members.window` |`git-ents/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window` +|`members.allowed-signers` |`git-ents/src/members.rs` (`allowed_signers`, `member_lines`) |`members::renders_a_wildcard_allowed_signers_file`, `renders_the_validity_window_as_comma_joined_options` +|`revocations.ref` |`git-ents/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations` +|`revocations.ca` |`git-ents/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched` +|`auth.bootstrap` |`git-ents-server/src/verify.rs` (`pre_receive`, empty-members early return) |`pre_receive` integration tests (bootstrap case) +|`auth.signed-push` |`git-ents-server/src/verify.rs` (`verify_certificate`) |`pre_receive::rejects_an_unsigned_push_when_signers_exist`, `rejects_a_push_signed_by_an_unknown_key` +|`auth.nonce` |`git-ents-server/src/http.rs` (`backend_config_injects_nonce_seed_and_hooks_path`) |`http::tests::backend_config_injects_nonce_seed_and_hooks_path` +|`auth.client-setup` |`git-ents/src/main.rs` (`setup`, `ensure_key`) | _(manual/CLI UX, no automated test)_ +|`cli.remote-admin` |`git-ents/src/main.rs` (`sync`, `push_signed`) | _(manual/CLI UX, no automated test)_ +|`cli.compare-and-swap` |`git-ents/src/main.rs` (`push_signed`, `push_delete`, `--force-with-lease`) | _(manual/CLI UX, no automated test)_ +|`cli.members` |`git-ents/src/main.rs` (`Action`, `run_members`) | _(manual/CLI UX, no automated test)_ +|`cli.interactive` |`git-ents/src/interactive.rs` | _(manual/CLI UX, no automated test)_ +|`cli.account-checks` |`git-ents/src/main.rs` (`AccountAction`, `ChecksAction`) | _(manual/CLI UX, no automated test)_ +|`cli.comments` |`git-ents/src/main.rs` (`CommentAction`, `run_comment`) | _(manual/CLI UX, no automated test)_ +|`cli.login` |`git-ents/src/main.rs` (`login`); `git-ents-server/src/main.rs` (`/login/cli` routes) | _(manual/CLI UX, no automated test)_ +|`cli.key-resolution` |`git-ents/src/main.rs` (`fingerprint`, MD5 colon form) | _(manual/CLI UX, no automated test)_ +|`account.ref` |`git-ents/src/account.rs` |`account::store_then_load_round_trips_the_account`, `the_account_ref_marks_an_account_repo` +|`account.genesis` |`git-ents/src/account.rs` (`genesis`) | _(exercised via `members add --account`; no dedicated unit test)_ +|`config.ref` |`git-ents/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent` +|`checks.definition` |`git-ents/src/checks.rs` (`load`/`store`, `CHECKS_REF`, `order`) |`checks::store_then_load_round_trips_the_check_set`, `store_then_load_round_trips_image_and_depends`, `order_runs_dependencies_first`, `order_rejects_a_cycle`, `order_rejects_an_unknown_dependency`, `order_rejects_self_and_duplicate_edges`, `order_rejects_an_empty_check` +|`checks.post-receive` |`git-ents-server/src/checks.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_ +|`checks.worker` |`git-ents-server/src/checks.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`checks::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-ents`)_ +|`checks.sandbox` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_ +|`checks.outcomes` |`git-ents/src/checks.rs` (`Status`, `record`/`update_run`); `git-ents-server/src/checks.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`checks::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_log_url` +|`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`checks debug`) | _(requires a live Sprite; manually verified)_ +|`issues.ref` |`git-ents/src/issues.rs` (`Issue`, `State`) |`issues::store_then_load_round_trips_an_issue` +|`issues.id` |`git-ents/src/issues.rs` (`new_id`, `promote`) |`issues::new_id_hashes_its_own_content_with_no_origin`, `promotion_assigns_a_number_and_advances_the_counter_without_renaming_the_ref` +|`comments.ref` |`git-comment/src/lib.rs` (`Comment`, `COMMENTS_NS`, `new_id`) |`git-comment::store_then_load_round_trips_a_comment`, `loads_the_on_disk_comment_format`, `new_id_hashes_its_own_content_with_no_origin` +|`comments.authorship` |`git-store/src/lib.rs` (`store_item_authored`, `provenance`); `git-comment/src/lib.rs` (`provenance`) |`git-store::provenance_recovers_the_creating_and_updating_authors`, `git-comment::provenance_comes_from_the_commits_not_the_document` +|`comments.anchor` |`git-anchor/src/lib.rs` (`Anchor`, `capture`, `snippet`) |`git-anchor::capture_records_the_commit_and_blob_and_snippet_derives_the_text`, `capture_rejects_a_missing_path_and_an_oversized_range` +|`comments.projection` |`git-anchor/src/lib.rs` (`project`, `Projection`) |`git-anchor::unchanged_file_projects_as_current`, `a_pure_rename_relocates_with_the_same_lines`, `an_edit_inside_the_range_is_outdated`, `a_deleted_file_projects_as_deleted`, `projection_works_backwards_onto_an_ancestor` +|`web.server-rendered` |`git-ents-server/src/web/*.rs` (Askama/maud templates, no client JS required) | _(manual UI verification)_ +|`web.index` |`git-ents-server/src/web/mod.rs` (`index`) | _(manual UI verification)_ +|`web.tabs` |`git-ents-server/src/web/pages.rs`, `templates/issues.html` | _(manual UI verification)_ +|`web.syntax-highlight` |`git-ents-server/src/web/git.rs` (`capped_read`, 2 MiB cap); `pages.rs` (rendered/source toggle, `#L<n>` anchors) |`web::git::tests::capped_read_flags_oversized_output` +|`web.comments` |`git-ents-server/src/web/pages.rs` (comment listing/form); `write.rs` (`add_comment`) | _(manual UI verification)_ +|`web.auth.challenge` |`git-ents-server/src/web/write.rs` (`issue_challenge`, `take_challenge`) |`write::tests::a_consumed_challenge_does_not_verify_twice` +|`web.auth.session` |`git-ents-server/src/web/write.rs` (`Session`, `csrf_ok`, `logout`) |`web_edit::an_edit_without_a_valid_csrf_token_is_refused` +|`web.auth.edit` |`git-ents-server/src/web/write.rs` (`edit_config`, `signed_edit`, `require_admin_registered`) |`web_edit::a_member_edits_settings_through_the_browser`, `a_self_attested_member_is_refused_a_settings_edit` +|`web.auth.webauthn-onboarding` | _Planned — see the requirement's note_ | — +|`nonfunctional.push-latency` |`git-ents-server/src/checks.rs` (`post_receive` returns after `enqueue`) | _(structural; see `checks.post-receive`)_ +|`nonfunctional.memory-cap` |`git-ents-server/src/web/git.rs` (`capped_read`, `capped_read_bytes`) |`web::git::tests::capped_read_*` +|`nonfunctional.concurrency` |`git-ents-server/src/http.rs` (`backend`, concurrent stdin/stdout); `checks.rs` (`spawn_blocking`) |`server::responds_to_requests` +|`nonfunctional.no-panic` |workspace-wide clippy lint configuration (`#![forbid(clippy::unwrap_used, ...)]`) |`cargo clippy --workspace --all-targets` +|`nonfunctional.no-unsafe` |workspace-wide (`#![forbid(unsafe_code)]`) |`cargo clippy --workspace --all-targets` +|`nonfunctional.object-store` |`git-store/src/lib.rs` (`Store::open`, common-dir odb) | _(see the module's own doc comment; exercised by hook integration tests)_ +|`compat.git` |`git-ents-server/src/http.rs`, `web/git.rs` (subprocess invocations) | _(implicit in all integration tests)_ +|`compat.ssh-keygen` |`git-ents-server/src/verify.rs` (`verify_certificate`), `web/write.rs` (`verify_login_signature`) |`pre_receive::*`, `web_edit::*` +|`compat.openssh-signed-push` |`git-ents-server/src/verify.rs` (`GIT_PUSH_CERT`, `GIT_PUSH_CERT_NONCE_STATUS`) |`pre_receive::rejects_a_push_signed_by_an_expired_key` +|`compat.sprite` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`) | _(requires a live Sprite; not covered by unit tests)_ +|`compat.cgi` |`git-ents-server/src/http.rs` (`backend`, CGI env vars) |`server::push_then_clone_round_trip` +|`compat.edition` |workspace `Cargo.toml` (`edition = "2024"`, `publish = false`) | _(build-time)_ +|`deploy.fly` |`.config/fly.toml` | _(deployment-time)_ +|`deploy.health` |`git-ents-server/src/main.rs` (`/healthz` route) | _(manual/liveness check)_ +|===
docs/spec/deployment.adoc @@ -1,0 +1,19 @@ +== Deployment + +[role="requirement", id="deploy.fly"] +.Fly.io Deployment +-- +The server MUST be deployable to Fly.io. +Fly configuration MUST live at `.config/fly.toml` and all `fly`/`flyctl` +invocations MUST pass `-c .config/fly.toml`. +-- + +[role="requirement", id="deploy.health"] +.Health Check +-- +The server MUST expose a liveness probe at `GET /healthz` that returns `200` +with body `ok` without touching any git repository, so the platform can route +traffic before any repository exists. +`GET /` itself is served by the web UI as the repository index +(<<web.index>>), not the probe. +--
docs/spec/issues.adoc @@ -1,0 +1,27 @@ +== Issues + +[role="requirement", id="issues.ref"] +.Issue Documents +-- +Each issue MUST be stored at `refs/meta/issues/<id>` as an `Issue` document +with fields: `title`, `body`, `state` (`open` or `closed`), `labels` (plain +strings, no separate registry), `author`, and `id` (the friendly number +described below). +One ref per issue keeps issues independently loadable and separately historied; +the ref's commit chain is the issue's edit history. +-- + +[role="requirement", id="issues.id"] +.Issue Identity +-- +An issue's stable identifier MUST be a content hash — the object id of the +originating object, or of the issue's own initial content when it has none +upstream — and MUST be the issue ref's last path segment, never renamed. +This identifier is conflict-free and requires no counter. +An issue MAY additionally carry a friendly sequential number, assigned only +when a maintainer promotes it; before promotion this number is absent. +Only promotion advances the number counter, so filing an issue never +contends it. +Any future cross-referencing feature (comments, reviews, and the like) MUST +key off the stable content-hash identifier, not the friendly number. +--
docs/spec/members.adoc @@ -1,0 +1,79 @@ +== Members + +[role="requirement", id="members.ref"] +.Member Refs +-- +The push trust root MUST be the set of refs matching `refs/meta/member/*`. +Each ref, `refs/meta/member/<username>`, holds one `Member` document for the +person named by the ref's last segment. +The set is decomposed — one ref per person — so adding, refreshing, or revoking +a member is an independent, separately-historied operation. +-- + +[role="requirement", id="members.trust"] +.Member Trust Modes +-- +A member's trust MUST rest on exactly one of three mutually exclusive bases: + +Keys:: + A set of leaf signing keys, mapping each fingerprint to its OpenSSH public + key. + The solo and small-team default. + +Certificate Authority:: + A pinned certificate authority's OpenSSH public key. + Any certificate the CA issues for the member's principal, within the + certificate's own validity window, is trusted. + The enterprise option: rotation, expiry, and new devices require no edit to + the member ref. + +WebAuthn:: + A set of passkey credentials in CASE form, keyed by credential ID, each with + a human-readable label. WebAuthn credentials authorize browser sign-in only + and MUST NOT produce `allowed_signers` lines or authorize git push. +-- + +[role="requirement", id="members.provenance"] +.Member Provenance +-- +Every member MUST carry a `provenance` recording whether they were +admin-registered or self-attested via web onboarding. A member ref written +before this field existed MUST load as admin-registered. A self-attested +member MUST be granted limited trust: the web service MUST refuse their +writes outside the allowed set — issues and comments (<<web.comments>>) — +and they MUST NOT be trusted for signed git push, until an admin promotes +them. +-- + +[role="requirement", id="members.account"] +.Account Link +-- +A member MAY carry an optional `account` field `@`-mentioning their account +repository by its genesis identity (<<account.genesis>>), so the link +survives the account repository moving or being renamed. +An absent field means no account is linked. +-- + +[role="requirement", id="members.window"] +.Trust Window +-- +A member MAY carry an optional `valid-after` / `valid-before` window, expressed +as OpenSSH timestamps (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`). +An un-refreshed member whose window has lapsed MUST stop authorizing new pushes +— stale trust fails closed. +A previously-valid push MUST remain verifiable forever by pinning the +verification time to the push date via `ssh-keygen -Y verify -Overify-time`. +-- + +[role="requirement", id="members.allowed-signers"] +.Allowed Signers Rendering +-- +The server MUST render the live member set as an OpenSSH `allowed_signers` file +for `ssh-keygen -Y verify`. +The principal column MUST be a wildcard (`*`) so any email matches. +Each member's validity window MUST be rendered as comma-joined options +(`valid-after`, `valid-before`). +Each leaf key MUST appear on its own line; a pinned CA MUST appear as a +`cert-authority` line. +All lines MUST carry `namespaces="git"`. +--
docs/spec/namespace.adoc @@ -1,0 +1,31 @@ +== Namespace + +[role="requirement", id="namespace.url"] +.Service URL +-- +The service URL MUST be `git-ents.sh/git-ents`. +-- + +[role="requirement", id="namespace.path"] +.Repository Path Validation +-- +A repository path MUST consist of one to three segments, each drawn from a +conservative character set: ASCII alphanumerics plus `.`, `_`, and `-`. +No segment may be empty, begin with `.`, or contain a path separator. +A path that would escape the data directory, nest inside an existing +repository, or collide with a namespace directory that is not a bare repository +MUST be rejected before `git http-backend` is invoked. +-- + +[role="requirement", id="namespace.auto-create"] +.Automatic Repository Creation +-- +Pushing to a previously unused name MUST create a new bare repository, +initialized with `http.receivepack = true` so smart-HTTP pushes are accepted. +Two concurrent first pushes to the same name MUST NOT both initialize the same +repository: creation MUST be serialized behind a per-server lock. +After a successful push, if `HEAD` points at a branch that does not exist, the +server MUST update `HEAD` to point at the pushed branch, preferring `main`, +then `master`, then the first available branch, so a fresh clone always checks +out content. +--
docs/spec/nonfunctional.adoc @@ -1,0 +1,59 @@ +== Nonfunctional + +[role="requirement", id="nonfunctional.push-latency"] +.Push Latency +-- +A push MUST NOT block on check execution. +The `post-receive` hook MUST return as soon as job files are written to the +queue, so the client's `git push` connection is released before any check runs. +-- + +[role="requirement", id="nonfunctional.memory-cap"] +.Per-Request Memory Cap +-- +No single web request MAY read more than 2 MiB of git object data into memory +for rendering. +Blobs, diffs, and other rendered output that exceed this limit MUST be +truncated and the UI MUST display a notice rather than an error. +-- + +[role="requirement", id="nonfunctional.concurrency"] +.Concurrency Model +-- +The HTTP server MUST handle concurrent requests without blocking the async +runtime on synchronous work. +Check jobs MUST be run on blocking threads (off the async executor) so a +long-running check cannot starve unrelated HTTP handlers. +The request body MUST be written to `git http-backend`'s stdin concurrently +with draining its stdout; a sequential write-then-read would deadlock when +`receive-pack` streams progress to the client before it has consumed the full +pack. +-- + +[role="requirement", id="nonfunctional.no-panic"] +.No Panics +-- +The implementation MUST NOT use `unwrap`, `expect`, unchecked indexing/slicing, +or other constructs that can panic in production code paths. +Any suppression of a panic-prevention lint MUST be accompanied by a documented +reason (`#[expect(..., reason = "...")]`); silent `#[allow(...)]` attributes are +forbidden. +-- + +[role="requirement", id="nonfunctional.no-unsafe"] +.No Unsafe Code +-- +The implementation MUST NOT contain any `unsafe` code blocks. +-- + +[role="requirement", id="nonfunctional.object-store"] +.Durable Object Store Reads +-- +Meta-ref documents MUST be read and written against the repository's common +object directory rather than any quarantine overlay. +Inside a `pre-receive` or `post-receive` hook, git points the per-object-path +environment variables at a receive-pack quarantine that holds only the incoming +pack; the durable store, where meta-refs live, is the common directory. +All meta-ref access MUST open the object database at the common directory +explicitly so it is not accidentally limited to the quarantine. +--
docs/spec/overview.adoc @@ -1,0 +1,13 @@ += Specification +Joey Carpinelli <joseph.carpinelli@icloud.com> + +[abstract] +`git-ents` is a Git forge: a self-hosted, membership-gated Git server with a +browser UI, a CLI, asynchronous CI checks, an issue tracker, and anchored +code comments, all stored as typed documents on git meta-refs. +Every piece of state — members, configuration, checks, runs, issues, comments +— lives in the repository itself, versioned and auditable, with no external +database. + +[CAUTION] +This specification is not yet stable and will grow as the project develops.
docs/spec/protocol.adoc @@ -1,0 +1,34 @@ +== Protocol + +[role="requirement", id="protocol.git"] +.Git Remote Compatibility +-- +The server MUST function as a full Git remote, supporting clone, fetch, and +push via the standard Git smart-HTTP pack protocol (`git-upload-pack` and +`git-receive-pack`) without any special client configuration. +The implementation MUST delegate the git wire protocol to `git http-backend` +running as a CGI subprocess, translating between HTTP requests and the CGI's +stdin/stdout. +-- + +[role="requirement", id="protocol.routing"] +.Request Routing +-- +A single HTTP listener MUST serve both the git wire protocol and the browser +web UI on the same port. +Requests are routed by inspecting the path and query: + +* Smart-HTTP service requests (`/info/refs?service=...`, + `/git-upload-pack`, `/git-receive-pack`) and dumb-HTTP object paths + (`/objects/...`) MUST be forwarded to `git http-backend`. +* Browser tree/blob/commit browse paths (`/tree/`, `/blob/`, `/commit/`) + MUST be served by the web UI, even when a file path within them resembles a + dumb-HTTP git path (e.g. a file named `HEAD` or a directory named `objects`). +* Reserved top-level paths (`/login`, `/_debug`, `/healthz`) are served by + the web UI and shadow a repository of the same name. +* All other GET requests MUST be served by the web UI. +* POST requests to the git smart-HTTP RPC paths + (`/git-upload-pack`, `/git-receive-pack`) MUST be forwarded to + `git http-backend`. + All other POST requests MUST be handled by the web UI. +--
docs/spec/revocations.adoc @@ -1,0 +1,19 @@ +== Revocations + +[role="requirement", id="revocations.ref"] +.Revocation List +-- +A single ref, `refs/meta/revoked`, MUST hold the revocation list: a map from +fingerprint to a free-text reason. +This is the "faster than expiry" override: the server MUST subtract every +revoked fingerprint from the trust set before verifying a push, so a +compromised key is refused the moment it is listed. +-- + +[role="requirement", id="revocations.ca"] +.CA Revocation +-- +Revoking a certificate authority MUST be done by removing the CA member's ref. +A CA is named by its ref, not by a fingerprint, so the revocation list operates +only on leaf-key fingerprints. +--
docs/spec/storage.adoc @@ -1,0 +1,115 @@ +== Storage + +[role="requirement", id="storage.bare"] +.Persistent Bare Repository +-- +Repository state MUST be persisted in a bare Git repository on durable storage. +A bare repository is created automatically on the first push to any previously +unused name and is never deleted by the server. +-- + +[role="requirement", id="storage.meta-ref"] +.Meta-Ref Documents +-- +All structured server-side state (members, configuration, checks, run results, +issues, comments, account profiles) MUST be stored as typed documents on +dedicated +`refs/meta/*` refs, one ref per document or per entity, using the +`facet-git-tree` serialization: each document becomes a git tree, wrapped in a +commit parented on the ref's prior tip, so every write is a fast-forward and +every ref's commit chain is the document's full history. + +A document's `Facet` shape IS its on-disk format. +An incompatible change (a renamed field, a changed field type) silently breaks +reading data already on a ref. +Each document type MUST carry a load test against a hand-built fixture in the +exact on-disk layout to catch a regression at test time rather than in +production. +-- + +[role="requirement", id="storage.concurrency"] +.Concurrent Writes +-- +Concurrent writes to the same `refs/meta/*` ref MUST use compare-and-swap. +When a write finds the ref has moved since it was read, the writer MUST +attempt a structural three-way merge of the document (base / ours / theirs): +non-overlapping changes — different fields, or different entries of a +collection — MUST merge cleanly. +A genuine conflict (the same scalar value changed two different ways) MUST +fail cleanly so the caller can reload and reapply. +Data MUST NEVER be silently overwritten, and a real conflict MUST NEVER be +silently resolved by picking a winner. +An in-place state advance (a run's progression) is a deliberate replace and +MUST fail cleanly on a race rather than merge. +-- + +=== Invariants & Abstractions + +This section documents, rather than mandates, how `git-store` (the crate +implementing <<storage.meta-ref>> and <<storage.concurrency>>) makes the +storage layer's invariants explicit in code instead of leaving them to be +re-derived per module. It exists so the next module added to `git-ents` +reuses these instead of re-inventing them. + +Key strategy:: + Every meta-ref document falls into one of three shapes. A *singleton* lives + on one fixed ref (`config`, `account`, `checks`, `revoked`, + `issue-number`) and is read and written with `Store::load`/`store`. A + *named collection* is decomposed one-ref-per-item under a namespace prefix, + keyed by a caller-supplied name (`member/<username>`) or a scalar-keyed map + on a single ref (`checks/<name>`, `revoked/<fingerprint>`, + `runs/<commit>/results/<name>`) — `Store::load_item`/`store_item`, + `load_map`/`store_map`. A *content-addressed* collection is keyed by the + hash of its own content (`issues/<genesis-hash>`, + `comments/<genesis-hash>`) via the shared `git_store::new_id` + (origin-or-content-hash), so filing an item never contends a counter and + the identity rule cannot drift between collections. + +Authored collections:: + A collection whose documents treat the commit as the record + (`comments/<genesis-hash>`) writes through `Store::store_item_authored`, + which stamps the acting human on the ref's commit, and reads authorship + back through `Store::provenance`/`item_provenance`: the genesis commit's + author created the document, the tip commit's author last updated it. + Neither an author nor a timestamp field is duplicated into the document + tree. + +Collection-key safety:: + `git_store::ref_segment_ok` is the one place a collection key (a username, a + check name, a colon-form fingerprint, a content hash) is checked before it + becomes a ref path segment or tree entry name: 1-64 ASCII alphanumerics, + `.`, `_`, `-`, or `:`, never starting with `.`, never containing `/`. + `Store::store_item`, `store_keyed`, and `store_map` all enforce it, failing + with `Error::InvalidKey` rather than silently injecting an extra path + component or colliding with a sibling entry. + +The map-document helper:: + `Store::load_map`/`store_map` is the shared conversion every "named entries + on one ref" collection needs — a scalar-keyed map document whose flattened + public item type carries the key once, not the hand-written wrapper + document (a `Checks { checks: BTreeMap<...> }`-shaped struct) each of + `checks`, `revocations`, and run outcomes previously declared for itself. + +Domain validation:: + A type that carries an invariant the type system cannot express (a + member's validity window must not be inverted) exposes its own `validate` + and calls it from its own `store`, returning `git_store::Error::Invalid` — + distinct from `InvalidKey`, which is about the collection key rather than + the document's content. This runs for every caller of that `store` + function, not just the CLI command that happens to build the value today. + +Closed sets are enums, not strings:: + `Issue.state`, and a check run's status, are closed sets the specification + enumerates (<<issues.ref>>, <<checks.outcomes>>); they are modeled as + `facet`-derived enums (`issues::State`, `checks::Status`) rather than + `String`, so an invalid value cannot be constructed instead of merely being + discouraged by a comment. + +Format stability:: + Unchanged from <<storage.meta-ref>>: a document's `Facet` shape is its + on-disk format, and every document type carries a load test against a + hand-built fixture in the exact on-disk layout. Introducing `load_map` and + the closed-set enums above changed several documents' on-disk shape + (`checks/<name>`, `revoked/<fingerprint>`, `runs/<commit>` outcomes, and + `issues/<id>` state each moved a scalar to a subtree); each carries an + updated fixture rather than a version negotiation, acceptable pre-1.0.
docs/spec/web-auth.adoc @@ -1,0 +1,69 @@ +== Web Authentication + +[role="requirement", id="web.auth.challenge"] +.Challenge–Response Sign-In +-- +Browser sign-in MUST NOT require a private key to be transmitted. +The server MUST issue a one-time nonce (challenge); the member MUST sign the +nonce locally with their web key using SSHSIG under the `git.ents.cloud` +namespace (distinct from the git push namespace) and paste back the public key +and signature. +The server MUST verify the pasted signature against the pasted key, and the key +against the live member list for the repository being edited, before opening a +session. +A challenge MUST expire after 600 seconds and MUST be consumed on first use so +it cannot be replayed. +The sign-in page MUST point at `git ents login` (<<cli.login>>) as the +preferred flow, keeping the manual signing instructions as the fallback for +a browser without the CLI installed. +-- + +[role="requirement", id="web.auth.session"] +.Sessions and CSRF +-- +A successful sign-in MUST issue a session cookie (`ents_session`) containing an +unguessable random token. +Each session MUST carry a separate CSRF token that state-changing POST requests +MUST echo back, so a cross-site request (which cannot read the cookie) cannot +act as the signed-in user. +Sessions MUST be held only in server memory; no session state is persisted to +disk. +A session MUST store only the member's public key and display label — no +private key is ever held or transmitted. +Signing out MUST drop the session from server memory and clear the session +cookie; the sign-out POST MUST itself carry the CSRF token, so a cross-site +request cannot force a sign-out. +-- + +[role="requirement", id="web.auth.edit"] +.Authenticated Settings Edit +-- +A settings edit MUST be landed as a real `git push --signed` onto +`refs/meta/config`, signed with the server's own member key, through the same +`pre-receive` gate a CLI push traverses. +The commit's author MUST be the signed-in human (resolved from their session's +public key to their member username); the committer MUST be the server identity. +The edit MUST be staged on a throwaway ref and pushed onto `refs/meta/config` +via the signed push; the staging ref MUST be deleted whether or not the push +succeeds. +If the server is not configured with a signing key or nonce seed, the Settings +tab MUST hide the edit controls rather than present a form that cannot succeed. +-- + +[role="requirement", id="web.auth.webauthn-onboarding"] +.Passkey Onboarding +-- +[NOTE] +Planned, not yet implemented. `Trust::WebAuthn` and `Provenance::SelfAttestedWeb` +exist and are exercised by `members::allowed_signers` (a `WebAuthn` member never +produces a push line) and by the web write path (`require_admin_registered` +refuses a self-attested member's edit), but no endpoint yet lets a new member +create that ref by proving a passkey. Tracked so this section states an +intended feature rather than current behavior. + +A new member MAY onboard without a CLI by proving control of a passkey in the +browser. The server MUST verify the attestation server-side and write the new +member ref with `provenance` set to self-attested, recording the attestation +evidence in the member ref's first commit as an audit record. The resulting +member gets limited trust until an admin promotes them. +--
docs/spec/web.adoc @@ -1,0 +1,74 @@ +== Web UI + +[role="requirement", id="web.server-rendered"] +.Server-Rendered HTML +-- +All browser-facing pages MUST be rendered server-side with no required +JavaScript on the client. +Page navigation MUST be ordinary links; folder expansion and file viewing MUST +be plain GET requests to the server. +-- + +[role="requirement", id="web.index"] +.Repository Index +-- +The web root (`GET /`) MUST render an index of the repositories discoverable +under the data directory, each linking to its overview page. +When no repository exists yet, the index MUST show a blank-slate prompt +explaining that a push creates one, rather than an error. +-- + +[role="requirement", id="web.tabs"] +.Repository Tabs +-- +Each repository's web UI MUST provide at minimum the following tabs: + +Files:: + The repository's file tree, browsable to arbitrary depth, with syntax- + highlighted blob views and rendered AsciiDoc and Markdown files. + +Commits:: + The commit history with diff views. + +Releases:: + Git tags treated as release milestones, browsable by version. + +Checks:: + The configured check set and every recorded run with per-check outcomes. + +Issues:: + The issue list with open/closed filter and per-issue detail view. + +Settings:: + The repository's `Config` fields (`description`, `homepage`, `topics`), + editable in the browser by signed-in members. +-- + +[role="requirement", id="web.syntax-highlight"] +.Syntax Highlighting +-- +Blob views MUST syntax-highlight source files using a compile-time language +registry. +AsciiDoc and Markdown files MUST be rendered to HTML. +A blob rendered as a formatted document MUST offer a toggle between the +rendered document and its syntax-highlighted source. +Blob gutter line numbers MUST be self-linking anchors (`#L<n>`). +Files larger than 2 MiB MUST be truncated rather than loaded in full, to bound +memory cost per request. +-- + +[role="requirement", id="web.comments"] +.Comments in the Browser +-- +A file's anchored comments MUST be listed under its blob view, projected +onto the revision being viewed (<<comments.projection>>), linking their line +range into the blob's gutter and flagged when the projection reports them +outdated. +A signed-in member MUST be able to add a comment from the file view; the +comment anchors to the viewed tip and MUST land as a signed push through the +same `pre-receive` gate a settings edit traverses (<<web.auth.edit>>), with +the signed-in human as author. +Commenting MUST NOT require admin-registered provenance: issues and comments +are exactly the writable surface allowed to a self-attested member +(<<members.provenance>>). +--
docs/specification.adoc @@ -1,1046 +1,0 @@ -= Specification -Joey Carpinelli <joseph.carpinelli@icloud.com> - -[abstract] -`git-ents` is a Git forge: a self-hosted, membership-gated Git server with a -browser UI, a CLI, asynchronous CI checks, an issue tracker, and anchored -code comments, all stored as typed documents on git meta-refs. -Every piece of state — members, configuration, checks, runs, issues, comments -— lives in the repository itself, versioned and auditable, with no external -database. - -[CAUTION] -This specification is not yet stable and will grow as the project develops. - -== Requirements - -=== Storage - -[role="requirement", id="storage.bare"] -.Persistent Bare Repository --- -Repository state MUST be persisted in a bare Git repository on durable storage. -A bare repository is created automatically on the first push to any previously -unused name and is never deleted by the server. --- - -[role="requirement", id="storage.meta-ref"] -.Meta-Ref Documents --- -All structured server-side state (members, configuration, checks, run results, -issues, comments, account profiles) MUST be stored as typed documents on -dedicated -`refs/meta/*` refs, one ref per document or per entity, using the -`facet-git-tree` serialization: each document becomes a git tree, wrapped in a -commit parented on the ref's prior tip, so every write is a fast-forward and -every ref's commit chain is the document's full history. - -A document's `Facet` shape IS its on-disk format. -An incompatible change (a renamed field, a changed field type) silently breaks -reading data already on a ref. -Each document type MUST carry a load test against a hand-built fixture in the -exact on-disk layout to catch a regression at test time rather than in -production. --- - -[role="requirement", id="storage.concurrency"] -.Concurrent Writes --- -Concurrent writes to the same `refs/meta/*` ref MUST use compare-and-swap. -When a write finds the ref has moved since it was read, the writer MUST -attempt a structural three-way merge of the document (base / ours / theirs): -non-overlapping changes — different fields, or different entries of a -collection — MUST merge cleanly. -A genuine conflict (the same scalar value changed two different ways) MUST -fail cleanly so the caller can reload and reapply. -Data MUST NEVER be silently overwritten, and a real conflict MUST NEVER be -silently resolved by picking a winner. -An in-place state advance (a run's progression) is a deliberate replace and -MUST fail cleanly on a race rather than merge. --- - -=== Invariants & Abstractions - -This section documents, rather than mandates, how `git-store` (the crate -implementing <<storage.meta-ref>> and <<storage.concurrency>>) makes the -storage layer's invariants explicit in code instead of leaving them to be -re-derived per module. It exists so the next module added to `git-ents` -reuses these instead of re-inventing them. - -Key strategy:: - Every meta-ref document falls into one of three shapes. A *singleton* lives - on one fixed ref (`config`, `account`, `checks`, `revoked`, - `issue-number`) and is read and written with `Store::load`/`store`. A - *named collection* is decomposed one-ref-per-item under a namespace prefix, - keyed by a caller-supplied name (`member/<username>`) or a scalar-keyed map - on a single ref (`checks/<name>`, `revoked/<fingerprint>`, - `runs/<commit>/results/<name>`) — `Store::load_item`/`store_item`, - `load_map`/`store_map`. A *content-addressed* collection is keyed by the - hash of its own content (`issues/<genesis-hash>`, - `comments/<genesis-hash>`) via the shared `git_store::new_id` - (origin-or-content-hash), so filing an item never contends a counter and - the identity rule cannot drift between collections. - -Authored collections:: - A collection whose documents treat the commit as the record - (`comments/<genesis-hash>`) writes through `Store::store_item_authored`, - which stamps the acting human on the ref's commit, and reads authorship - back through `Store::provenance`/`item_provenance`: the genesis commit's - author created the document, the tip commit's author last updated it. - Neither an author nor a timestamp field is duplicated into the document - tree. - -Collection-key safety:: - `git_store::ref_segment_ok` is the one place a collection key (a username, a - check name, a colon-form fingerprint, a content hash) is checked before it - becomes a ref path segment or tree entry name: 1-64 ASCII alphanumerics, - `.`, `_`, `-`, or `:`, never starting with `.`, never containing `/`. - `Store::store_item`, `store_keyed`, and `store_map` all enforce it, failing - with `Error::InvalidKey` rather than silently injecting an extra path - component or colliding with a sibling entry. - -The map-document helper:: - `Store::load_map`/`store_map` is the shared conversion every "named entries - on one ref" collection needs — a scalar-keyed map document whose flattened - public item type carries the key once, not the hand-written wrapper - document (a `Checks { checks: BTreeMap<...> }`-shaped struct) each of - `checks`, `revocations`, and run outcomes previously declared for itself. - -Domain validation:: - A type that carries an invariant the type system cannot express (a - member's validity window must not be inverted) exposes its own `validate` - and calls it from its own `store`, returning `git_store::Error::Invalid` — - distinct from `InvalidKey`, which is about the collection key rather than - the document's content. This runs for every caller of that `store` - function, not just the CLI command that happens to build the value today. - -Closed sets are enums, not strings:: - `Issue.state`, and a check run's status, are closed sets the specification - enumerates (<<issues.ref>>, <<checks.outcomes>>); they are modeled as - `facet`-derived enums (`issues::State`, `checks::Status`) rather than - `String`, so an invalid value cannot be constructed instead of merely being - discouraged by a comment. - -Format stability:: - Unchanged from <<storage.meta-ref>>: a document's `Facet` shape is its - on-disk format, and every document type carries a load test against a - hand-built fixture in the exact on-disk layout. Introducing `load_map` and - the closed-set enums above changed several documents' on-disk shape - (`checks/<name>`, `revoked/<fingerprint>`, `runs/<commit>` outcomes, and - `issues/<id>` state each moved a scalar to a subtree); each carries an - updated fixture rather than a version negotiation, acceptable pre-1.0. - -=== Protocol - -[role="requirement", id="protocol.git"] -.Git Remote Compatibility --- -The server MUST function as a full Git remote, supporting clone, fetch, and -push via the standard Git smart-HTTP pack protocol (`git-upload-pack` and -`git-receive-pack`) without any special client configuration. -The implementation MUST delegate the git wire protocol to `git http-backend` -running as a CGI subprocess, translating between HTTP requests and the CGI's -stdin/stdout. --- - -[role="requirement", id="protocol.routing"] -.Request Routing --- -A single HTTP listener MUST serve both the git wire protocol and the browser -web UI on the same port. -Requests are routed by inspecting the path and query: - -* Smart-HTTP service requests (`/info/refs?service=...`, - `/git-upload-pack`, `/git-receive-pack`) and dumb-HTTP object paths - (`/objects/...`) MUST be forwarded to `git http-backend`. -* Browser tree/blob/commit browse paths (`/tree/`, `/blob/`, `/commit/`) - MUST be served by the web UI, even when a file path within them resembles a - dumb-HTTP git path (e.g. a file named `HEAD` or a directory named `objects`). -* Reserved top-level paths (`/login`, `/_debug`, `/healthz`) are served by - the web UI and shadow a repository of the same name. -* All other GET requests MUST be served by the web UI. -* POST requests to the git smart-HTTP RPC paths - (`/git-upload-pack`, `/git-receive-pack`) MUST be forwarded to - `git http-backend`. - All other POST requests MUST be handled by the web UI. --- - -=== Namespace - -[role="requirement", id="namespace.url"] -.Service URL --- -The service URL MUST be `git-ents.sh/git-ents`. --- - -[role="requirement", id="namespace.path"] -.Repository Path Validation --- -A repository path MUST consist of one to three segments, each drawn from a -conservative character set: ASCII alphanumerics plus `.`, `_`, and `-`. -No segment may be empty, begin with `.`, or contain a path separator. -A path that would escape the data directory, nest inside an existing -repository, or collide with a namespace directory that is not a bare repository -MUST be rejected before `git http-backend` is invoked. --- - -[role="requirement", id="namespace.auto-create"] -.Automatic Repository Creation --- -Pushing to a previously unused name MUST create a new bare repository, -initialized with `http.receivepack = true` so smart-HTTP pushes are accepted. -Two concurrent first pushes to the same name MUST NOT both initialize the same -repository: creation MUST be serialized behind a per-server lock. -After a successful push, if `HEAD` points at a branch that does not exist, the -server MUST update `HEAD` to point at the pushed branch, preferring `main`, -then `master`, then the first available branch, so a fresh clone always checks -out content. --- - -=== Members - -[role="requirement", id="members.ref"] -.Member Refs --- -The push trust root MUST be the set of refs matching `refs/meta/member/*`. -Each ref, `refs/meta/member/<username>`, holds one `Member` document for the -person named by the ref's last segment. -The set is decomposed — one ref per person — so adding, refreshing, or revoking -a member is an independent, separately-historied operation. --- - -[role="requirement", id="members.trust"] -.Member Trust Modes --- -A member's trust MUST rest on exactly one of three mutually exclusive bases: - -Keys:: - A set of leaf signing keys, mapping each fingerprint to its OpenSSH public - key. - The solo and small-team default. - -Certificate Authority:: - A pinned certificate authority's OpenSSH public key. - Any certificate the CA issues for the member's principal, within the - certificate's own validity window, is trusted. - The enterprise option: rotation, expiry, and new devices require no edit to - the member ref. - -WebAuthn:: - A set of passkey credentials in CASE form, keyed by credential ID, each with - a human-readable label. WebAuthn credentials authorize browser sign-in only - and MUST NOT produce `allowed_signers` lines or authorize git push. --- - -[role="requirement", id="members.provenance"] -.Member Provenance --- -Every member MUST carry a `provenance` recording whether they were -admin-registered or self-attested via web onboarding. A member ref written -before this field existed MUST load as admin-registered. A self-attested -member MUST be granted limited trust: the web service MUST refuse their -writes outside the allowed set — issues and comments (<<web.comments>>) — -and they MUST NOT be trusted for signed git push, until an admin promotes -them. --- - -[role="requirement", id="members.account"] -.Account Link --- -A member MAY carry an optional `account` field `@`-mentioning their account -repository by its genesis identity (<<account.genesis>>), so the link -survives the account repository moving or being renamed. -An absent field means no account is linked. --- - -[role="requirement", id="members.window"] -.Trust Window --- -A member MAY carry an optional `valid-after` / `valid-before` window, expressed -as OpenSSH timestamps (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`). -An un-refreshed member whose window has lapsed MUST stop authorizing new pushes -— stale trust fails closed. -A previously-valid push MUST remain verifiable forever by pinning the -verification time to the push date via `ssh-keygen -Y verify -Overify-time`. --- - -[role="requirement", id="members.allowed-signers"] -.Allowed Signers Rendering --- -The server MUST render the live member set as an OpenSSH `allowed_signers` file -for `ssh-keygen -Y verify`. -The principal column MUST be a wildcard (`*`) so any email matches. -Each member's validity window MUST be rendered as comma-joined options -(`valid-after`, `valid-before`). -Each leaf key MUST appear on its own line; a pinned CA MUST appear as a -`cert-authority` line. -All lines MUST carry `namespaces="git"`. --- - -=== Revocations - -[role="requirement", id="revocations.ref"] -.Revocation List --- -A single ref, `refs/meta/revoked`, MUST hold the revocation list: a map from -fingerprint to a free-text reason. -This is the "faster than expiry" override: the server MUST subtract every -revoked fingerprint from the trust set before verifying a push, so a -compromised key is refused the moment it is listed. --- - -[role="requirement", id="revocations.ca"] -.CA Revocation --- -Revoking a certificate authority MUST be done by removing the CA member's ref. -A CA is named by its ref, not by a fingerprint, so the revocation list operates -only on leaf-key fingerprints. --- - -=== Push Authentication - -[role="requirement", id="auth.bootstrap"] -.Bootstrap Window --- -When no `refs/meta/member/*` refs exist the repository MUST accept any push -without a signature, so the first member can be pushed in. -Once at least one member ref exists the bootstrap window MUST close and all -subsequent pushes MUST be signed. -Revoking every member's keys — leaving the member refs present but all keys -removed — MUST fail closed, not reopen the bootstrap window. --- - -[role="requirement", id="auth.signed-push"] -.Signed Push Verification --- -Every push to a repository whose member list is non-empty MUST carry a signed -push certificate (`git push --signed`). -The server MUST verify the certificate in a `pre-receive` hook before any ref -is updated: - -. The push certificate nonce status (from `GIT_PUSH_CERT_NONCE_STATUS`) MUST - be `OK`. -. The certificate MUST contain an SSH signature (an - `-----BEGIN SSH SIGNATURE-----` block). -. The signature MUST verify against at least one authorized member key via - `ssh-keygen -Y verify -n git`. -. The trust set fed to `ssh-keygen` MUST be the live member set minus any - revoked fingerprints. - -A push that fails any check MUST be rejected before any ref is updated. --- - -[role="requirement", id="auth.nonce"] -.Nonce Configuration --- -The server MUST configure `receive.certNonceSeed` on every `git http-backend` -invocation when authentication is active. -The server MUST configure `receive.certNonceSlop = 60` to tolerate the -round-trip latency inherent in smart-HTTP, where the nonce is issued and -verified by two separate `receive-pack` processes. --- - -[role="requirement", id="auth.client-setup"] -.Client-Side Setup --- -A CLI command (`git ents members setup`) MUST configure the client's local or -global git config to sign pushes with an SSH key, using the key named by -`user.signingkey` or a default `~/.ssh/id_ed25519`. --- - -=== Client CLI - -[role="requirement", id="cli.remote-admin"] -.Remote Administration Over Git --- -The `git-ents` CLI MUST administer a remote's trust and metadata refs as -ordinary signed git pushes, with no separate admin API or server endpoint. -A mutating command MUST fetch the relevant `refs/meta/*` ref(s) from the named -remote (defaulting to `origin`) into the local clone, load the typed document, -apply the change, store it, and push the updated ref back — signed per the -client's git config, through the same `pre-receive` gate a content push -traverses. -A read-only command (`members list`, `members check`, `checks list`, -`comment list`/`show`) MUST only fetch, never push. --- - -[role="requirement", id="cli.interactive"] -.Interactive Prompting --- -A mutating command run at an interactive terminal MUST prompt for any -required field left unset rather than fail. -The same invocation without a TTY MUST fail with an error naming the missing -field; it MUST NOT hang waiting for input. --- - -[role="requirement", id="cli.compare-and-swap"] -.Optimistic Concurrency --- -Every CLI push MUST be a compare-and-swap pinned to the ref tip observed at -fetch time, expressed as `--force-with-lease=<ref>:<expected>` together with -`--force-if-includes`, so a change made on the remote since the fetch is -rejected rather than clobbered; a create pins the lease to the zero object id. -This is the client-side counterpart to <<storage.concurrency>>. --- - -[role="requirement", id="cli.members"] -.Member Commands --- -The CLI MUST provide, under `git ents members`: - -* `list` — render the live member set, each key with its fingerprint, validity - window, and a flag when it appears on the revocation list. -* `add` — authorize a leaf key or pin a certificate authority for a username, - with optional `valid-after` / `valid-before` window; adding a member MUST - create the ref when absent. -* `remove` — delete the member's ref. -* `revoke` / `unrevoke` — add or remove a fingerprint on `refs/meta/revoked`. -* `check` — report whether a key is a member of the remote and echo the - client's signing config (`gpg.format`, `user.signingkey`, `push.gpgSign`). -* `setup` — configure signed pushes (see <<auth.client-setup>>). - -Revoking the operator's own last authorizing key MUST prompt for confirmation -before proceeding, since it can lock the operator out of the remote. --- - -[role="requirement", id="cli.account-checks"] -.Account and Check Commands --- -`git ents account create` MUST write or update `refs/meta/account` -(<<account.ref>>) and print the account's genesis identity -(<<account.genesis>>). -`git ents checks` MUST provide `list`, `add`, and `remove` over the -`refs/meta/checks` set (<<checks.definition>>). --- - -[role="requirement", id="cli.comments"] -.Comment Commands --- -The CLI MUST provide, under `git ents comment`: - -* `add` — anchor a comment to a path, an optional 1-based inclusive line - range, and a revision (defaulting to `HEAD`), optionally attached to an - issue by its genesis id, and push it. -* `list` / `show` — project each comment's anchor onto a chosen revision and - report its projection state (<<comments.projection>>); `show` MUST print - the anchored text derived from the blob, and both MUST report the author - recovered from the ref's commits (<<comments.authorship>>). -* `remove` — delete the comment's ref. - -Comment commands MUST follow the same remote-administration flow as member -commands (<<cli.remote-admin>>, <<cli.compare-and-swap>>). --- - -[role="requirement", id="cli.login"] -.CLI Sign-In --- -`git ents login` MUST complete the same challenge–response sign-in the -browser uses (<<web.auth.challenge>>) with no manual copy-and-paste: the CLI -fetches a challenge from the remote, signs it with the client's configured -signing key, submits the proof, and receives the same session a browser -sign-in yields. -The server MUST expose the challenge and verify steps as plain-text -endpoints suitable for non-browser clients. --- - -[role="requirement", id="cli.key-resolution"] -.Key Resolution and Fingerprints --- -The key a command operates on MUST default to `user.signingkey`, accepting an -inline `key::` value, a `.pub` file, or a private key whose public half is -derived with `ssh-keygen -y`; `members setup` MAY generate a new -`~/.ssh/id_ed25519` when none exists. -A key's fingerprint MUST be the MD5 colon form, whose separators are safe as a -ref-path/tree-entry segment, unlike the slashes a base64 SHA256 fingerprint -would introduce. --- - -=== Account - -[role="requirement", id="account.ref"] -.Account Profile --- -A repository becomes an account repository by carrying a `refs/meta/account` -ref. -The `Account` document at that ref MUST hold: `username`, `display_name`, -`bio`, and `created_at` (seconds since the Unix epoch). -The username is authoritative in the document; the repository path is -convention, not trust. --- - -[role="requirement", id="account.genesis"] -.Account Identity --- -An account's stable, path-independent identity MUST be the content hash of -the first `Account` document ever recorded on its ref — fixed at creation, -so later profile edits never change it and a reference to the account -(<<members.account>>) survives the account repository moving. -The identity MUST be derived from the ref's history, never stored as a -field. --- - -=== Repository Configuration - -[role="requirement", id="config.ref"] -.Configuration Ref --- -A repository's loose metadata MUST be stored at `refs/meta/config` as a -`Config` document with fields: `description`, `homepage`, and `topics`. -Keeping metadata on a meta ref means a push of ordinary content cannot rewrite -it, and the metadata carries its own independent history. -An absent ref MUST yield the default (all empty / zero). --- - -=== Checks - -[role="requirement", id="checks.definition"] -.Check Set Definition --- -The configured checks for a repository MUST be stored at `refs/meta/checks` as -a map from check name to a check body holding an optional shell command, an -optional sandbox image, and an optional list of dependencies (names of sibling -checks that must pass first). -A check with no command is a composite: it runs nothing itself and derives its -outcome from its dependencies alone. -A check's definition living on a meta ref means a branch under check cannot -rewrite the check set that gates it. - -The dependency graph is fully static — no conditional edges, no runtime -expansion — and MUST be validated when the set is written: a dependency naming -no configured check, a duplicate or self edge, a check with neither a command -nor dependencies, and any dependency cycle MUST each be rejected before the -set is stored. A check that sets an image MUST be rejected until the sandbox -can honor one, rather than the image being silently ignored; the field is -reserved in the format so honoring it later is not a data migration. --- - -[role="requirement", id="checks.post-receive"] -.Asynchronous Queuing --- -After a successful push is accepted by `pre-receive`, a `post-receive` hook -MUST enqueue a check job for each updated branch whose new tip is non-zero and -is not a `refs/meta/*` ref. -The hook MUST record an initial run status of `queued` for each check -immediately, so the UI reflects the job before the worker picks it up. -The hook MUST NOT run checks itself; it MUST return as soon as jobs are -enqueued, so the push is never blocked on check execution. -Job files MUST be written to a tmp path and renamed into place so the worker -never observes a partial file. --- - -[role="requirement", id="checks.worker"] -.Check Worker --- -A persistent server-side worker MUST drain the job queue. -Jobs MUST be grouped by repository; each repository's jobs MUST be processed -serially to prevent concurrent runs from colliding in its sandbox. -Separate repositories MUST be processed concurrently, so a slow repository's -backlog does not block others. - -Within a job the checks MUST settle in topological dependency order, so every -dependency's outcome is terminal before its dependents are reached. -A check whose dependency did not pass MUST be recorded `skipped` without -running; a composite's outcome is derived from its dependencies — `pass` when -all passed, `fail` when any failed or errored, `skipped` otherwise. -The worker MUST re-validate the dependency graph before running (a hand-crafted -push can land a set the CLI would have rejected) and finalize the run as -`error` when it is invalid. --- - -[role="requirement", id="checks.sandbox"] -.Sprite Sandbox --- -Each check MUST run inside a Fly.io Sprite: a persistent, hardware-isolated -sandbox, one per repository, so build caches survive between pushes. -Before running checks the worker MUST sync the pushed tree into the Sprite's -work directory via `git archive` piped to `tar -x`. -The worker MUST configure the `sprite` CLI from `SPRITES_TOKEN` before each -run via `sprite auth setup`, so the credential stays current without restart. --- - -[role="requirement", id="checks.outcomes"] -.Run Recording --- -Run outcomes MUST be stored at `refs/meta/runs/<commit>`, one ref per checked -commit. -Each ref's commit chain is the run history; each commit's date is the run time, -so no timestamp is duplicated in the document tree. -Outcome values progress: `queued` → `running` → `pass` / `fail` / `error` / -`skipped` (a check whose dependency did not pass, per <<checks.worker>>). -A run that cannot complete due to an infrastructure error (unreachable Sprite, -failed sync) MUST be finalized as `error` rather than left stuck at `running`. -A single check that exceeds a 30-minute timeout MUST be recorded `error` rather -than blocking the worker thread indefinitely. -A check definition and a run outcome MAY each carry additional metadata beyond -a single command/outcome string (for example a run's duration and log URL). -Optional fields absent from an older record MUST load as unset. -A check/outcome name is the map key and MUST NOT be stored redundantly inside -the record. --- - -[role="requirement", id="checks.debug"] -.Sprite Debug Shell --- -A signed-in member MUST be able to open an interactive, read-write shell in -a repository's checks Sprite (`git ents checks debug`), brokered by the -server over a WebSocket at the reserved path `/_debug/<repo>`. -The server holds the only Fly credential (`SPRITES_TOKEN`); a member MUST -NOT need one of their own. -The broker MUST refuse the connection without a signed-in session. -Local terminal resizes MUST be forwarded to the remote pseudo-terminal as -control frames distinct from the byte stream, so the remote shell tracks the -member's window size. --- - -=== Issues - -[role="requirement", id="issues.ref"] -.Issue Documents --- -Each issue MUST be stored at `refs/meta/issues/<id>` as an `Issue` document -with fields: `title`, `body`, `state` (`open` or `closed`), `labels` (plain -strings, no separate registry), `author`, and `id` (the friendly number -described below). -One ref per issue keeps issues independently loadable and separately historied; -the ref's commit chain is the issue's edit history. --- - -[role="requirement", id="issues.id"] -.Issue Identity --- -An issue's stable identifier MUST be a content hash — the object id of the -originating object, or of the issue's own initial content when it has none -upstream — and MUST be the issue ref's last path segment, never renamed. -This identifier is conflict-free and requires no counter. -An issue MAY additionally carry a friendly sequential number, assigned only -when a maintainer promotes it; before promotion this number is absent. -Only promotion advances the number counter, so filing an issue never -contends it. -Any future cross-referencing feature (comments, reviews, and the like) MUST -key off the stable content-hash identifier, not the friendly number. --- - -=== Comments - -[role="requirement", id="comments.ref"] -.Comment Documents --- -Each code comment MUST be stored at `refs/meta/comments/<id>` as a `Comment` -document with fields: `body`, `anchor` (<<comments.anchor>>), and an -optional `issue` cross-referencing an issue by its genesis id -(<<issues.id>>); an absent `issue` means a free-standing comment. -The identifier MUST follow the same genesis-key rule as an issue: the object -id of the originating object, or of the comment's own initial content, and -it is never renamed. -One ref per comment keeps comments independently loadable and separately -historied; the ref's commit chain is the comment's edit history. --- - -[role="requirement", id="comments.authorship"] -.Authorship From the Commit Chain --- -A comment's author and timestamps MUST NOT be stored in the document tree. -The creator is the author of the ref's first commit and the last editor is -the author of its tip commit; both MUST be recovered from the commit chain -at read time. --- - -[role="requirement", id="comments.anchor"] -.Anchors --- -A comment MUST be anchored to the exact content it was written against: the -commit, the repository-relative file path, the file's blob object id, and an -optional 1-based inclusive line range (absent for a whole-file comment). -An anchor is authoritative at creation and MUST NEVER be mutated afterwards. -Creating an anchor MUST validate the path and line range against the -revision's actual content. -The anchored text is fully derivable from the blob and the line range and -MUST be derived at read time, never stored redundantly. --- - -[role="requirement", id="comments.projection"] -.Anchor Projection --- -Displaying a comment against any revision other than its own MUST be a -read-time projection of its anchor, reporting one of four outcomes: -*current* (the exact blob still sits at the anchored path), *relocated* (the -file moved and/or the range shifted past edits outside it, reported with the -new path and range), *outdated* (an edit touched the anchored region, or the -entry is no longer a regular file), or *deleted* (the file is gone). -Projection MUST follow renames and MUST work between any two commits — -forwards, backwards, or across unrelated history. -An outdated or deleted projection MUST NOT lose the comment: the original -anchor remains displayable. --- - -=== Web UI - -[role="requirement", id="web.server-rendered"] -.Server-Rendered HTML --- -All browser-facing pages MUST be rendered server-side with no required -JavaScript on the client. -Page navigation MUST be ordinary links; folder expansion and file viewing MUST -be plain GET requests to the server. --- - -[role="requirement", id="web.index"] -.Repository Index --- -The web root (`GET /`) MUST render an index of the repositories discoverable -under the data directory, each linking to its overview page. -When no repository exists yet, the index MUST show a blank-slate prompt -explaining that a push creates one, rather than an error. --- - -[role="requirement", id="web.tabs"] -.Repository Tabs --- -Each repository's web UI MUST provide at minimum the following tabs: - -Files:: - The repository's file tree, browsable to arbitrary depth, with syntax- - highlighted blob views and rendered AsciiDoc and Markdown files. - -Commits:: - The commit history with diff views. - -Releases:: - Git tags treated as release milestones, browsable by version. - -Checks:: - The configured check set and every recorded run with per-check outcomes. - -Issues:: - The issue list with open/closed filter and per-issue detail view. - -Settings:: - The repository's `Config` fields (`description`, `homepage`, `topics`), - editable in the browser by signed-in members. --- - -[role="requirement", id="web.syntax-highlight"] -.Syntax Highlighting --- -Blob views MUST syntax-highlight source files using a compile-time language -registry. -AsciiDoc and Markdown files MUST be rendered to HTML. -A blob rendered as a formatted document MUST offer a toggle between the -rendered document and its syntax-highlighted source. -Blob gutter line numbers MUST be self-linking anchors (`#L<n>`). -Files larger than 2 MiB MUST be truncated rather than loaded in full, to bound -memory cost per request. --- - -[role="requirement", id="web.comments"] -.Comments in the Browser --- -A file's anchored comments MUST be listed under its blob view, projected -onto the revision being viewed (<<comments.projection>>), linking their line -range into the blob's gutter and flagged when the projection reports them -outdated. -A signed-in member MUST be able to add a comment from the file view; the -comment anchors to the viewed tip and MUST land as a signed push through the -same `pre-receive` gate a settings edit traverses (<<web.auth.edit>>), with -the signed-in human as author. -Commenting MUST NOT require admin-registered provenance: issues and comments -are exactly the writable surface allowed to a self-attested member -(<<members.provenance>>). --- - -=== Web Authentication - -[role="requirement", id="web.auth.challenge"] -.Challenge–Response Sign-In --- -Browser sign-in MUST NOT require a private key to be transmitted. -The server MUST issue a one-time nonce (challenge); the member MUST sign the -nonce locally with their web key using SSHSIG under the `git.ents.cloud` -namespace (distinct from the git push namespace) and paste back the public key -and signature. -The server MUST verify the pasted signature against the pasted key, and the key -against the live member list for the repository being edited, before opening a -session. -A challenge MUST expire after 600 seconds and MUST be consumed on first use so -it cannot be replayed. -The sign-in page MUST point at `git ents login` (<<cli.login>>) as the -preferred flow, keeping the manual signing instructions as the fallback for -a browser without the CLI installed. --- - -[role="requirement", id="web.auth.session"] -.Sessions and CSRF --- -A successful sign-in MUST issue a session cookie (`ents_session`) containing an -unguessable random token. -Each session MUST carry a separate CSRF token that state-changing POST requests -MUST echo back, so a cross-site request (which cannot read the cookie) cannot -act as the signed-in user. -Sessions MUST be held only in server memory; no session state is persisted to -disk. -A session MUST store only the member's public key and display label — no -private key is ever held or transmitted. -Signing out MUST drop the session from server memory and clear the session -cookie; the sign-out POST MUST itself carry the CSRF token, so a cross-site -request cannot force a sign-out. --- - -[role="requirement", id="web.auth.edit"] -.Authenticated Settings Edit --- -A settings edit MUST be landed as a real `git push --signed` onto -`refs/meta/config`, signed with the server's own member key, through the same -`pre-receive` gate a CLI push traverses. -The commit's author MUST be the signed-in human (resolved from their session's -public key to their member username); the committer MUST be the server identity. -The edit MUST be staged on a throwaway ref and pushed onto `refs/meta/config` -via the signed push; the staging ref MUST be deleted whether or not the push -succeeds. -If the server is not configured with a signing key or nonce seed, the Settings -tab MUST hide the edit controls rather than present a form that cannot succeed. --- - -[role="requirement", id="web.auth.webauthn-onboarding"] -.Passkey Onboarding --- -[NOTE] -Planned, not yet implemented. `Trust::WebAuthn` and `Provenance::SelfAttestedWeb` -exist and are exercised by `members::allowed_signers` (a `WebAuthn` member never -produces a push line) and by the web write path (`require_admin_registered` -refuses a self-attested member's edit), but no endpoint yet lets a new member -create that ref by proving a passkey. Tracked so this section states an -intended feature rather than current behavior. - -A new member MAY onboard without a CLI by proving control of a passkey in the -browser. The server MUST verify the attestation server-side and write the new -member ref with `provenance` set to self-attested, recording the attestation -evidence in the member ref's first commit as an audit record. The resulting -member gets limited trust until an admin promotes them. --- - -=== Nonfunctional - -[role="requirement", id="nonfunctional.push-latency"] -.Push Latency --- -A push MUST NOT block on check execution. -The `post-receive` hook MUST return as soon as job files are written to the -queue, so the client's `git push` connection is released before any check runs. --- - -[role="requirement", id="nonfunctional.memory-cap"] -.Per-Request Memory Cap --- -No single web request MAY read more than 2 MiB of git object data into memory -for rendering. -Blobs, diffs, and other rendered output that exceed this limit MUST be -truncated and the UI MUST display a notice rather than an error. --- - -[role="requirement", id="nonfunctional.concurrency"] -.Concurrency Model --- -The HTTP server MUST handle concurrent requests without blocking the async -runtime on synchronous work. -Check jobs MUST be run on blocking threads (off the async executor) so a -long-running check cannot starve unrelated HTTP handlers. -The request body MUST be written to `git http-backend`'s stdin concurrently -with draining its stdout; a sequential write-then-read would deadlock when -`receive-pack` streams progress to the client before it has consumed the full -pack. --- - -[role="requirement", id="nonfunctional.no-panic"] -.No Panics --- -The implementation MUST NOT use `unwrap`, `expect`, unchecked indexing/slicing, -or other constructs that can panic in production code paths. -Any suppression of a panic-prevention lint MUST be accompanied by a documented -reason (`#[expect(..., reason = "...")]`); silent `#[allow(...)]` attributes are -forbidden. --- - -[role="requirement", id="nonfunctional.no-unsafe"] -.No Unsafe Code --- -The implementation MUST NOT contain any `unsafe` code blocks. --- - -[role="requirement", id="nonfunctional.object-store"] -.Durable Object Store Reads --- -Meta-ref documents MUST be read and written against the repository's common -object directory rather than any quarantine overlay. -Inside a `pre-receive` or `post-receive` hook, git points the per-object-path -environment variables at a receive-pack quarantine that holds only the incoming -pack; the durable store, where meta-refs live, is the common directory. -All meta-ref access MUST open the object database at the common directory -explicitly so it is not accidentally limited to the quarantine. --- - -=== Compatibility - -[role="requirement", id="compat.git"] -.Git Tooling --- -The server MUST invoke `git` (including `git http-backend`, `git receive-pack`, -`git archive`, `git cat-file`, `git for-each-ref`, `git symbolic-ref`, and -`git init --bare`) as external subprocesses. -Git MUST be present on `PATH` at runtime. -Git config overrides MUST be passed via `GIT_CONFIG_COUNT` / -`GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` environment variables rather than -`git -c`, so they reach the `receive-pack` and `pre-receive` processes that -`git http-backend` spawns, not merely the CGI wrapper process. --- - -[role="requirement", id="compat.ssh-keygen"] -.OpenSSH `ssh-keygen` --- -Push certificate verification and browser sign-in verification MUST use -`ssh-keygen -Y verify` against an `allowed_signers` file written at runtime. -`ssh-keygen` (OpenSSH) MUST be present on `PATH` at runtime. -The `allowed_signers` format used MUST be the OpenSSH format: one line per key, -with a principal column, optional comma-joined options (`cert-authority`, -`valid-after`, `valid-before`, `namespaces`), and the key material. --- - -[role="requirement", id="compat.openssh-signed-push"] -.Signed Push Protocol --- -Push signatures MUST use OpenSSH (`gpg.format = ssh`) push certificates. -The server MUST read the pushed certificate's object ID from `GIT_PUSH_CERT` -and its nonce status from `GIT_PUSH_CERT_NONCE_STATUS` — the environment -variables git populates for the `pre-receive` hook — and MUST NOT assume any -other delivery mechanism. --- - -[role="requirement", id="compat.sprite"] -.Sprite CLI --- -Asynchronous checks MUST be executed via the `sprite` CLI -(`sprite auth setup`, `sprite create`, `sprite exec`). -The `sprite` binary MUST be present on `PATH` at runtime when checks are -configured. -The `sprite` CLI MUST be initialized per-push via `sprite auth setup --token` -from the `SPRITES_TOKEN` environment variable, since the CLI persists -credentials to a config file and does not read the token per invocation. --- - -[role="requirement", id="compat.cgi"] -.CGI Protocol --- -`git http-backend` is a CGI program. -The server MUST populate the standard CGI environment variables (`PATH_INFO`, -`QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE`, `CONTENT_LENGTH`, -`GIT_PROJECT_ROOT`, `GIT_HTTP_EXPORT_ALL`) before spawning it. -The server MUST parse the CGI response format (header block, `\r\n\r\n` -separator, body) and translate it into an HTTP response. --- - -[role="requirement", id="compat.edition"] -.Rust Edition --- -All crates MUST target Rust edition 2024. -The workspace MUST NOT be published to crates.io (`publish = false`). --- - -=== Deployment - -[role="requirement", id="deploy.fly"] -.Fly.io Deployment --- -The server MUST be deployable to Fly.io. -Fly configuration MUST live at `.config/fly.toml` and all `fly`/`flyctl` -invocations MUST pass `-c .config/fly.toml`. --- - -[role="requirement", id="deploy.health"] -.Health Check --- -The server MUST expose a liveness probe at `GET /healthz` that returns `200` -with body `ok` without touching any git repository, so the platform can route -traffic before any repository exists. -`GET /` itself is served by the web UI as the repository index -(<<web.index>>), not the probe. --- - -[appendix] -== Conformance - -A map from each requirement to the module or function implementing it and the -test guarding it, so a requirement with no entry (or an entry marked -_Planned_) is immediately visible as unimplemented rather than discovered by -reading every module. Update this table in the same change that adds, -renames, or removes a requirement or its implementation. - -[cols="1,2,2", options="header"] -|=== -|Requirement |Implementation |Guarding test - -|`storage.bare` |`git-ents-server/src/http.rs` (`backend`, repo auto-init) |`server::push_then_clone_round_trip` -|`storage.meta-ref` |`git-store/src/lib.rs` (`Store::load`/`store`) |per-document `loads_the_on_disk_*_format` fixture tests -|`storage.concurrency` |`git-store/src/merge.rs` (`three_way_merge`), `Store::store_impl`/`amend` |`git-store::tests::merge_*`, `amend_fails_closed_on_a_race_instead_of_merging` -|`protocol.git` |`git-ents-server/src/http.rs` (`backend`) |`server::push_then_clone_round_trip` -|`protocol.routing` |`git-ents-server/src/http.rs` (`get_request`/`post_request`, `detects_pushes`) |`http::tests::detects_pushes`, `routes_browser_gets` -|`namespace.url` |`README.adoc`/deployment config | _(documentation only)_ -|`namespace.path` |`git-ents-server/src/http.rs` (segment validation) |`http::tests::validates_segments`, `extracts_repo_path` -|`namespace.auto-create` |`git-ents-server/src/http.rs` (`backend` init lock, `reconcile_head`) |`server::rejects_colliding_pushes` -|`members.ref` |`git-ents/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref` -|`members.trust` |`git-ents/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}` -|`members.provenance` |`git-ents/src/members.rs` (`Provenance`); `git-ents-server/src/web/write.rs` (`require_admin_registered`) |`members::loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered`, `web_edit::a_self_attested_member_is_refused_a_settings_edit` -|`members.account` |`git-ents/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member` -|`members.window` |`git-ents/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window` -|`members.allowed-signers` |`git-ents/src/members.rs` (`allowed_signers`, `member_lines`) |`members::renders_a_wildcard_allowed_signers_file`, `renders_the_validity_window_as_comma_joined_options` -|`revocations.ref` |`git-ents/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations` -|`revocations.ca` |`git-ents/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched` -|`auth.bootstrap` |`git-ents-server/src/verify.rs` (`pre_receive`, empty-members early return) |`pre_receive` integration tests (bootstrap case) -|`auth.signed-push` |`git-ents-server/src/verify.rs` (`verify_certificate`) |`pre_receive::rejects_an_unsigned_push_when_signers_exist`, `rejects_a_push_signed_by_an_unknown_key` -|`auth.nonce` |`git-ents-server/src/http.rs` (`backend_config_injects_nonce_seed_and_hooks_path`) |`http::tests::backend_config_injects_nonce_seed_and_hooks_path` -|`auth.client-setup` |`git-ents/src/main.rs` (`setup`, `ensure_key`) | _(manual/CLI UX, no automated test)_ -|`cli.remote-admin` |`git-ents/src/main.rs` (`sync`, `push_signed`) | _(manual/CLI UX, no automated test)_ -|`cli.compare-and-swap` |`git-ents/src/main.rs` (`push_signed`, `push_delete`, `--force-with-lease`) | _(manual/CLI UX, no automated test)_ -|`cli.members` |`git-ents/src/main.rs` (`Action`, `run_members`) | _(manual/CLI UX, no automated test)_ -|`cli.interactive` |`git-ents/src/interactive.rs` | _(manual/CLI UX, no automated test)_ -|`cli.account-checks` |`git-ents/src/main.rs` (`AccountAction`, `ChecksAction`) | _(manual/CLI UX, no automated test)_ -|`cli.comments` |`git-ents/src/main.rs` (`CommentAction`, `run_comment`) | _(manual/CLI UX, no automated test)_ -|`cli.login` |`git-ents/src/main.rs` (`login`); `git-ents-server/src/main.rs` (`/login/cli` routes) | _(manual/CLI UX, no automated test)_ -|`cli.key-resolution` |`git-ents/src/main.rs` (`fingerprint`, MD5 colon form) | _(manual/CLI UX, no automated test)_ -|`account.ref` |`git-ents/src/account.rs` |`account::store_then_load_round_trips_the_account`, `the_account_ref_marks_an_account_repo` -|`account.genesis` |`git-ents/src/account.rs` (`genesis`) | _(exercised via `members add --account`; no dedicated unit test)_ -|`config.ref` |`git-ents/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent` -|`checks.definition` |`git-ents/src/checks.rs` (`load`/`store`, `CHECKS_REF`, `order`) |`checks::store_then_load_round_trips_the_check_set`, `store_then_load_round_trips_image_and_depends`, `order_runs_dependencies_first`, `order_rejects_a_cycle`, `order_rejects_an_unknown_dependency`, `order_rejects_self_and_duplicate_edges`, `order_rejects_an_empty_check` -|`checks.post-receive` |`git-ents-server/src/checks.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_ -|`checks.worker` |`git-ents-server/src/checks.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`checks::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-ents`)_ -|`checks.sandbox` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_ -|`checks.outcomes` |`git-ents/src/checks.rs` (`Status`, `record`/`update_run`); `git-ents-server/src/checks.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`checks::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_log_url` -|`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`checks debug`) | _(requires a live Sprite; manually verified)_ -|`issues.ref` |`git-ents/src/issues.rs` (`Issue`, `State`) |`issues::store_then_load_round_trips_an_issue` -|`issues.id` |`git-ents/src/issues.rs` (`new_id`, `promote`) |`issues::new_id_hashes_its_own_content_with_no_origin`, `promotion_assigns_a_number_and_advances_the_counter_without_renaming_the_ref` -|`comments.ref` |`git-comment/src/lib.rs` (`Comment`, `COMMENTS_NS`, `new_id`) |`git-comment::store_then_load_round_trips_a_comment`, `loads_the_on_disk_comment_format`, `new_id_hashes_its_own_content_with_no_origin` -|`comments.authorship` |`git-store/src/lib.rs` (`store_item_authored`, `provenance`); `git-comment/src/lib.rs` (`provenance`) |`git-store::provenance_recovers_the_creating_and_updating_authors`, `git-comment::provenance_comes_from_the_commits_not_the_document` -|`comments.anchor` |`git-anchor/src/lib.rs` (`Anchor`, `capture`, `snippet`) |`git-anchor::capture_records_the_commit_and_blob_and_snippet_derives_the_text`, `capture_rejects_a_missing_path_and_an_oversized_range` -|`comments.projection` |`git-anchor/src/lib.rs` (`project`, `Projection`) |`git-anchor::unchanged_file_projects_as_current`, `a_pure_rename_relocates_with_the_same_lines`, `an_edit_inside_the_range_is_outdated`, `a_deleted_file_projects_as_deleted`, `projection_works_backwards_onto_an_ancestor` -|`web.server-rendered` |`git-ents-server/src/web/*.rs` (Askama/maud templates, no client JS required) | _(manual UI verification)_ -|`web.index` |`git-ents-server/src/web/mod.rs` (`index`) | _(manual UI verification)_ -|`web.tabs` |`git-ents-server/src/web/pages.rs`, `templates/issues.html` | _(manual UI verification)_ -|`web.syntax-highlight` |`git-ents-server/src/web/git.rs` (`capped_read`, 2 MiB cap); `pages.rs` (rendered/source toggle, `#L<n>` anchors) |`web::git::tests::capped_read_flags_oversized_output` -|`web.comments` |`git-ents-server/src/web/pages.rs` (comment listing/form); `write.rs` (`add_comment`) | _(manual UI verification)_ -|`web.auth.challenge` |`git-ents-server/src/web/write.rs` (`issue_challenge`, `take_challenge`) |`write::tests::a_consumed_challenge_does_not_verify_twice` -|`web.auth.session` |`git-ents-server/src/web/write.rs` (`Session`, `csrf_ok`, `logout`) |`web_edit::an_edit_without_a_valid_csrf_token_is_refused` -|`web.auth.edit` |`git-ents-server/src/web/write.rs` (`edit_config`, `signed_edit`, `require_admin_registered`) |`web_edit::a_member_edits_settings_through_the_browser`, `a_self_attested_member_is_refused_a_settings_edit` -|`web.auth.webauthn-onboarding` | _Planned — see the requirement's note_ | — -|`nonfunctional.push-latency` |`git-ents-server/src/checks.rs` (`post_receive` returns after `enqueue`) | _(structural; see `checks.post-receive`)_ -|`nonfunctional.memory-cap` |`git-ents-server/src/web/git.rs` (`capped_read`, `capped_read_bytes`) |`web::git::tests::capped_read_*` -|`nonfunctional.concurrency` |`git-ents-server/src/http.rs` (`backend`, concurrent stdin/stdout); `checks.rs` (`spawn_blocking`) |`server::responds_to_requests` -|`nonfunctional.no-panic` |workspace-wide clippy lint configuration (`#![forbid(clippy::unwrap_used, ...)]`) |`cargo clippy --workspace --all-targets` -|`nonfunctional.no-unsafe` |workspace-wide (`#![forbid(unsafe_code)]`) |`cargo clippy --workspace --all-targets` -|`nonfunctional.object-store` |`git-store/src/lib.rs` (`Store::open`, common-dir odb) | _(see the module's own doc comment; exercised by hook integration tests)_ -|`compat.git` |`git-ents-server/src/http.rs`, `web/git.rs` (subprocess invocations) | _(implicit in all integration tests)_ -|`compat.ssh-keygen` |`git-ents-server/src/verify.rs` (`verify_certificate`), `web/write.rs` (`verify_login_signature`) |`pre_receive::*`, `web_edit::*` -|`compat.openssh-signed-push` |`git-ents-server/src/verify.rs` (`GIT_PUSH_CERT`, `GIT_PUSH_CERT_NONCE_STATUS`) |`pre_receive::rejects_a_push_signed_by_an_expired_key` -|`compat.sprite` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`) | _(requires a live Sprite; not covered by unit tests)_ -|`compat.cgi` |`git-ents-server/src/http.rs` (`backend`, CGI env vars) |`server::push_then_clone_round_trip` -|`compat.edition` |workspace `Cargo.toml` (`edition = "2024"`, `publish = false`) | _(build-time)_ -|`deploy.fly` |`.config/fly.toml` | _(deployment-time)_ -|`deploy.health` |`git-ents-server/src/main.rs` (`/healthz` route) | _(manual/liveness check)_ -|===