mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 18:08:02 -10:00
v0.4.0
71
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0190fbbbf6 |
chore(main): release 0.4.0 (#131)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>v0.4.0 |
||
|
|
86341d195d |
test(node): guard that every repo-scoped handler is visibility-gated (#122)
* test(node): guard that every repo-scoped handler is visibility-gated Mirrors the existing every_in_scope_mutation_has_its_gate source-string CI test on the egress side: scrapes every repo-scoped handler (any pub/pub(crate) async fn taking Path<(String,String..)>) and asserts it carries an authz marker, either a read gate (authorize_repo_read / visibility_check) or a write/owner/self gate (require_repo_owner / require_owner / did_matches / &auth.0), or is listed in KNOWN_UNGATED with its tracking issue. Verb-agnostic on purpose: a repo-scoped read named outside list_/get_ (git_info_refs, replicate_encrypted_blobs, withheld_paths) is still checked, so a new ungated egress of any name fails CI instead of shipping as the next leak. A completeness scan over src/api/ asserts every module declaring a repo-scoped handler is wired into the guard, so a whole new file cannot add an ungated handler the scrape never looks at. KNOWN_UNGATED is the live gap list (certs/issues/labels/ bounties/stars per #120, plus events/webhooks/replicas/protected pending PR #113); the staleness assert forces each entry out the moment its gate lands. A count floor trips if the scrape silently breaks, and unit tests pin handler_names and is_repo_scoped. Verified by execution: removing an allowlist entry trips the missing-gate assert; listing a gated handler trips the staleness assert; dropping a source file trips the completeness scan; breaking the parser trips the count floor; and a non-list/get handler (git_info_refs) is confirmed scraped and gate-checked. * test(node): don't let a service-conditional gate satisfy the egress guard (#122) The guard counted a handler as gated on bare substring presence of a gate marker, so git_info_refs false-passed on visibility_check( even though that check only runs under `if service == "git-upload-pack"` and leaves the git-receive-pack advertisement ungated. Add gate_runs_unconditionally, which ignores markers that sit only inside an `if service ==` block, and track git_info_refs in KNOWN_UNGATED until #119 makes the gate unconditional. When that lands, the staleness assert forces the entry's removal. * fix(review): harden gate_runs_unconditionally against unclosed blocks (#122) Code review of the egress-guard hardening: - Clamp the span-advance index so an unclosed `if service ==` block can't slice past the body end and panic (latent: real source is balanced, but the scraper should not crash on a pathological future body). Keep span-to-EOF as the fail-safe direction. - Document that only `if service ==` is detected, not `match service`, so a future match-arm gate isn't silently treated as full. - Add unit cases: gate inside both service blocks (conditional), gate inside and outside (full), and the unclosed-block no-panic regression. |
||
|
|
8b9ceec25e |
feat(node): iCaptcha-aware repo propagation gate with quarantine (#125)
Phase 2 of the iCaptcha work: extend the proof-of-intelligence gate from
direct writes to federated propagation, so a node that enforces iCaptcha
can defend against spam repos that originate on a node which does not.
The proof now travels with the repo and is re-verified at mirror time:
- Capture & persist: create_repo/fork_repo store the verified proof
(raw token + claims) against the new repo. New repo_icaptcha_proofs table.
- Serve: GET /api/v1/repos/{owner}/{repo}/icaptcha-proof returns the stored
token, gated on whole-repo "/" read like the other replication endpoints.
- Admit at mirror time: the sync worker fetches the origin's proof and
icaptcha::admit_mirror re-verifies it offline — signature + level +
owner-DID binding, but NOT expiry — and consumes the jti in the local
ledger so one solved challenge admits at most one mirror per node.
- Quarantine: in enforce mode a mirror that fails the gate is kept on disk
but hidden from clone/serve/push (authorize_repo_read + the git serve and
receive-pack handlers) and from listings (dedup CTE + federated list), via
a new repos.quarantined column. Shadow logs only; off admits normally.
Fail-safe: off/shadow/no-key always admit.
Hardening from review:
- git-receive-pack also rejects quarantined mirrors (no writes while hidden).
- mirror admission keys "first-time" on DB-row absence, not on-disk presence,
so a clone that crashed before the DB insert is still gated on retry.
- mirror jti is spent against a fixed forward retention window, not the
proof's already-past exp, so the replay record isn't swept early.
Known limitation: an iCaptcha proof binds to the owner DID, not the repo, so
a malicious origin can get one spam repo per harvested public proof admitted
(per-node jti single-use bounds the amplification). Fully binding a proof to
a repo needs a repo-scoped iCaptcha challenge (protocol change, future work).
The operator release HTTP endpoint is deferred (no admin-auth surface yet);
DB methods are in place.
Tested: verify_token expiry-skip + signature/level/DID enforcement on the
propagation path; proof round-trip; quarantine set/release, preservation
across re-sync, exclusion from listings; mirror jti retention survives a
sweep at the proof's exp. Full workspace green against Postgres.
|
||
|
|
12bfb1b3c8 |
fix(release): use simple release-type + generic Cargo.toml updaters (#130)
release-type: rust at path . is incompatible with this virtual workspace root. release-please's Rust strategy unconditionally appends a CargoToml update for the root Cargo.toml, and that updater throws "is not a package manifest (might be a cargo workspace)" whenever the manifest has no [package] section — so every run died on the final step. Switch to release-type: simple (touches only a version file + CHANGELOG, never a manifest) and bump each crate version through the generic updater via extra-files plus an inline x-release-please-version annotation. Preserves the single vX.Y.Z tag, all action outputs, and the entire downstream release.yml unchanged. |
||
|
|
788a8686c1 |
fix(release): give crates explicit versions for release-please (#129)
release-please's Rust workspace strategy versions each member crate in its own Cargo.toml and cannot rewrite an inherited `version.workspace = true`, so it failed with 'value at path package.version is not tagged'. Give each of the five crates an explicit version = "0.3.9" (kept in lockstep by release-please) and drop the now-unused [workspace.package].version. Inter-crate deps are path-only, so no version requirements need syncing. |
||
|
|
174f25a206 |
fix(node): gate GET /ipfs/{cid} on per-caller path-scoped visibility (#110) (#128)
* fix(node): gate GET /ipfs/{cid} on per-caller path-scoped visibility (#110)
get_by_cid served any git object by raw SHA-256 with no auth and no
visibility check. For a public repo with a path-scoped deny rule, an
unauthenticated caller could recover a withheld blob's oid from the
served trees, derive its CID, and fetch the cleartext here, bypassing
the pack-path withholding.
Bind optional caller identity (optional_signature on a dedicated
/ipfs/{cid} sub-router; the pins route stays unsigned, tracked by #121)
and gate each iterated repo row against its OWN rules: visibility_check
at "/" denies the repo, and withheld_blob_oids skips a blob withheld
from the caller when the row carries path-scoped rules. The row is gated
directly rather than via authorize_repo_read, whose fuzzy get_repo
re-resolve could authorize a different physical row than the one read.
The per-repo withheld set is memoized on repo.id within the request.
Adds get_by_cid to the authz_guard drift table (visibility_check marker).
Scope: this closes the direct unauthenticated scan. A stale-public
mirror row still serves withheld content on both this path and the pack
path; that pre-existing leak is tracked separately in #124.
* fix(review): batch visibility-rule lookup and fail-closed walk panic in get_by_cid
Code-review follow-ups for #110:
- Replace the per-repo list_visibility_rules call with a single
list_visibility_rules_for_repos batch query (the existing #97 pattern),
removing the N+1 on an unauthenticated scan and the "one repo's DB error
500s the whole request" semantics.
- Fail closed on a spawn_blocking JoinError (walk task panic) by skipping the
repo, matching the walk-error arm, instead of 500-ing the whole scan.
- Add a regression test for the repo-level "/" deny branch (private repo,
anon denied / owner served) and note the access gate in the module doc.
* test(node): cover the fail-closed walk-error arm in get_by_cid
Induce a withheld_blob_oids walk error (a ref pointing at a non-tree-ish
blob, same technique as visibility_pack::fails_closed_when_a_ref_cannot_be_traversed)
and assert the handler skips the whole repo: the withheld blob 404s with no
leak, and the public blob in the same repo also 404s (proving fail-closed-skip
rather than serve-on-error). Verified load-bearing: mutating the Ok(Err) arm to
treat the error as no-withholding makes it RED (secret served 200).
|
||
|
|
3f08d9936b |
Release automation (#127)
* ci(release): add Windows target and cross-platform packaging
Add x86_64-pc-windows-msvc to the release matrix. Windows ships a .zip
(7z) while unix keeps .tar.gz; a portable sha256 helper handles
sha256sum/shasum/certutil. Smoke test and packaging now resolve the
.exe suffix on Windows. Release attach uses globs to cover both formats.
* feat(release): vendor npm wrapper and auto-publish to npm
Vendor the @gitlawb/gl npm wrapper (optionalDependencies platform-package
pattern) into npm/, replacing the standalone gl-npm repo. A new npm-publish
job downloads the four unix release tarballs from the GitHub release, lays in
the binaries, stamps every package.json with the release version, and runs
npm publish --provenance. Guarded on NPM_TOKEN so it no-ops until the secret
is configured.
* feat(release): auto-bump Homebrew tap on release
Add a homebrew-bump job that regenerates Formula/gl.rb in Gitlawb/homebrew-tap
from the release tarballs' sha256 checksums (macOS+Linux, arm64+x64) and pushes
the bump. Guarded on HOMEBREW_TAP_PAT so it no-ops until the secret and tap repo
exist. Enables 'brew install gitlawb/tap/gl'.
* feat(release): Windows installer and web auto-sync on release
Add install.ps1 (Windows PowerShell installer, source of truth in node).
Add a web-sync job that opens a PR to Gitlawb/web on each release syncing
install.sh + install.ps1 + public/version.json, plus idempotent drift fixes
(remove orphaned public/bin, fix stale skill.md releases link). Guarded on
WEB_SYNC_PAT. Vercel deploys on merge.
* docs(readme): document npm, Homebrew, and Windows install methods
* ci(release): build only CLI binaries on Windows
The gitlawb-node daemon targets Linux/macOS; building it on Windows risks
a compile failure that would block the whole release (npm/brew/web jobs all
depend on release-binaries). Windows now builds only gl + git-remote-gitlawb;
the smoke-test and package loops follow the per-target binary list.
* fix(release): reconcile release-please manifest with v0.3.9 and bump action to v5
The manifest still said 0.3.8 while the only tag is a manual v0.3.9 and
Cargo.toml is 0.3.9, so release-please had no valid baseline (no v0.3.8 tag)
and never cut a release. Set the manifest to 0.3.9 so release-please diffs
v0.3.9..main and opens a proper release PR for the next version. Also pin
release-please-action to v5.0.0 to match the openclaude pipeline.
* ci(release): set explicit minimal permissions on homebrew-bump and web-sync
CodeQL flagged both jobs for lacking a permissions block, leaving GITHUB_TOKEN
at broad default scopes. Both only need contents: read (cross-repo writes use
dedicated PATs).
* fix(release): harden installers and workflow per review
- install.ps1: fail closed if the .sha256 cannot be fetched; pure ASCII (no BOM)
- npm install.js: fail (exit 1 / throw) when the platform payload or a binary is
missing instead of reporting a successful but broken install
- workflow: top-level permissions {} + persist-credentials: false on read-only
checkouts
- docs: PowerShell-only Windows note; text language tag on npm/README fenced block
* fix(installer): require core CLI binaries on Windows
install.ps1 only failed when zero binaries were present, so a partial archive
missing gl.exe could still report success. Require gl.exe + git-remote-gitlawb.exe
(gitlawb-node.exe stays optional, matching the Windows build) and fail early if a
required binary is absent.
* ci(release): make Windows non-blocking and stop persisting cross-repo PATs
- release-binaries: continue-on-error for the Windows leg so a failure there
no longer fails the job or blocks npm/Homebrew (matches the best-effort intent)
- homebrew-bump/web-sync: persist-credentials: false on the PAT-backed checkouts;
supply the token only on the git push via an x-access-token URL
* fix(release): gate web-sync on binaries and detect native arch in installer
- web-sync now needs release-binaries so the site never advertises a release
whose artifacts failed to build
- install.ps1 reads PROCESSOR_ARCHITEW6432 before PROCESSOR_ARCHITECTURE so a
32-bit PowerShell host on x64 Windows is not wrongly rejected
* ci(release): make npm publish idempotent across reruns
Skip any @gitlawb package version already on the registry so rerunning the
job after a partial publish no longer errors on the first existing package
and leaves the wrapper/platform packages out of sync.
* fix(release): harden web-sync and tap commit per review
- web-sync: verify the Windows ZIP + .sha256 exist on the release before
syncing install.ps1/version.json (Windows is best-effort, so a failed leg
must not make the site advertise a missing installer)
- stage generated files before the no-op check so brand-new untracked files
(formula, version.json) are committed instead of skipped
- gh pr create: only swallow the 'PR already exists' case; surface auth/API errors
- npm README: scope npm/yarn/pnpm/bun install to macOS/Linux/WSL, point native
Windows at the PowerShell installer
* ci(release): guard npm-publish before doing any work
Check NPM_TOKEN in an up-front guard step (like homebrew-bump/web-sync) and
skip checkout, release downloads, extraction, version rewrites, and publish
when it is absent, instead of doing all that work and bailing in Publish.
* fix(release): robust web PR error handling and Windows installer hardening
- web-sync: capture gh pr list output so a real API/auth failure surfaces
instead of being misread as 'PR already exists'
- install.ps1: enforce TLS 1.2 and use -UseBasicParsing so downloads work on
stock Windows PowerShell 5.1; remove binaries no longer shipped on upgrade so
an in-place update can't leave a mixed-version install; handle an empty user
PATH without a leading separator
|
||
|
|
adc20f9eff |
feat(node): iCaptcha proof-of-intelligence gate on create_repo + register (#108)
* feat(node): iCaptcha proof-of-intelligence gate on create_repo + register Add an optional gate that requires callers to present an iCaptcha proof (an Ed25519-signed token from icaptcha.gitlawb.com, earned by solving an escalating challenge) on the two spam-prone endpoints. Proofs are verified OFFLINE with ed25519-dalek against the published public key, and bound to the authenticated agent DID so they cannot be shared. Backward-compatible by default: ICAPTCHA_MODE is `off` unless set, so deploying changes nothing. `shadow` verifies and logs would-be rejections but always allows; `enforce` rejects requests lacking a valid, strong-enough proof. If the public key cannot be loaded at startup the gate stays inert (fail safe) so an iCaptcha outage can never break repo creation or registration. - New src/icaptcha.rs: env-driven verifier (OnceLock), /v1/pubkey fetch at startup or ICAPTCHA_PUBKEY override, proof parse + signature + expiry + level + DID-binding checks; mode-aware decide() split out for testing. - Wired into create_repo and register (X-ICaptcha-Proof header). git push (receive-pack) is intentionally NOT gated. - 11 unit tests incl. verification of a real captured proof (JS-signed, Rust-verified), tamper/expiry/wrong-DID/level/missing-header rejections, and the off/shadow/enforce/fail-safe mode matrix. Author: Kevin Codex <[email protected]> * fix(node): address review on icaptcha gate (validate config, bound key fetch) Addresses CodeRabbit and @jatmn review feedback on #108: - parse_mode now returns Option and rejects unrecognized ICAPTCHA_MODE values; init() warns and falls back to off instead of silently disabling the gate on a typo like "enforced". [P2] - ICAPTCHA_REQUIRED_LEVEL: warn on a non-empty unparseable value instead of silently lowering the threshold to the default. [P2] - fetch_key uses a reqwest client with a 5s timeout so a hung /v1/pubkey can never block node startup; still fails inert on timeout/error. [P2] - .env.example: reorder ICAPTCHA_* keys (MODE, PUBKEY, REQUIRED_LEVEL, URL) to satisfy dotenv-linter. [P3] - add parse_mode unit test (rejects junk, accepts documented values). cargo test -p gitlawb-node icaptcha: 12 passed. clippy + fmt clean. Author: Kevin Codex <[email protected]> * fix(node): consume iCaptcha proof jti so a proof can't be replayed Addresses @jatmn P2 on #108: the verifier checked exp/level/sub but never consumed the proof's jti, so one solved challenge was a reusable bearer proof for that DID until expiry, covering every gated create_repo/register in the window. Now, in enforce mode, each verified proof's jti is recorded once and replays are rejected. - db: migration v7 adds icaptcha_consumed_proofs(jti PK, expires_at); new consume_proof_jti (INSERT ON CONFLICT DO NOTHING -> bool) and sweep_expired_proofs. DB-backed so it holds across restarts and instances. - icaptcha: ProofClaims carries jti; verify returns the claims; decide() stays pure and returns Allow | Reject | Consume{jti,exp}; check() is now async and spends the jti via the DB, rejecting replays (enforce only; shadow observes without consuming). - handlers: create_repo/register await check(&state.db, ...). - main: periodic sweep of expired proof rows alongside rate-limit cleanup. - tests: 12 pass incl. decide() yields Consume with the proof's jti. Author: Kevin Codex <[email protected]> * fix(node): consume iCaptcha proof only after request passes validation Addresses CodeRabbit (Major) on #108: check() spent the jti as soon as the proof verified, but create_repo/register ran it before name/DID/existence checks, so a rejected request (bad name, already-exists, wrong DID) permanently burned a valid proof without the action succeeding. Split verification from consumption: - icaptcha: replace check() with verify_request() -> ProofGuard (verifies and rejects invalid/missing proofs early; off/shadow/inert yield a no-op guard) plus ProofGuard::consume(db) which spends the jti right before the write and rejects replays. Guard is #[must_use]. - create_repo/register: verify_request() up front, then consume() immediately before the first write, after cheap validation passes. Replay protection unchanged (consume-before-write). 12 tests pass; clippy clean. Author: Kevin Codex <[email protected]> * fix(node): give iCaptcha rejections a dedicated error code Addresses CodeRabbit on #108: iCaptcha failure paths returned AppError::Unauthorized, which maps to the "not_an_agent" API code and conflates a valid-but-unproven agent with a failed signature. Add a dedicated AppError::IcaptchaProofRequired variant mapped to the "icaptcha_proof_required" code (HTTP 401), and use it for both the missing/invalid-proof and replay paths so clients can distinguish "solve a challenge" from "your signature is invalid". 12 tests pass; clippy + fmt clean. Author: Kevin Codex <[email protected]> * fix(node): gate fork_repo with iCaptcha and cover the proof ledger Address remaining PR #108 review comments: - fork_repo was the third repo-creation entrypoint and bypassed the iCaptcha gate. Verify the proof up front (fail fast on missing/invalid) and consume it only just before the git clone, so cheap rejections (bad name, conflict, withheld subtree) never burn a valid proof — matching create_repo's verify/consume split. - Add #[sqlx::test] coverage for the single-use ledger that the gate's anti-replay value depends on: consume_proof_jti (first-use vs replay vs distinct jti) and sweep_expired_proofs (only expired rows removed, unexpired spent proofs still block replays). - Return 403 Forbidden (was 401) for IcaptchaProofRequired: the caller is authenticated but forbidden without a valid proof. The distinct icaptcha_proof_required code is unchanged. - Recommend pinning ICAPTCHA_PUBKEY in production in .env.example to avoid the startup fetch dependency. The two shadow-mode review comments were already resolved by the earlier verify/consume refactor (decide/verify are IO-free; check_proof_jti gone). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
828dd279a2 |
fix(node): gate repo-listing and stats surfaces on visibility (#97, #99, #101, #104) (#111)
* fix(node): NFC-normalize visibility matcher to close NFC/NFD under-withholding (#101) A deny rule authored in NFC byte-compared unequal to a path committed in NFD (or vice versa), so blob_paths/withheld_blob_oids failed to classify the blob and it shipped in cleartext on the replication/pack path. Normalize both the glob prefix and the candidate path to NFC at the single matcher seam (glob_matches + specificity); NFC not NFKC so compatibility forms are not folded into distinct paths. Live serve is unaffected (git resolves byte-exact; the gate only becomes more restrictive). Adds a matcher unit test and a replication-path integration test with the variant byte inside the rule-covered directory name. * fix(node): fail closed on the full-scan pin path to stop dangling-blob leak (#99) The withheld set is computed over reachable objects (rev-list --all + HEAD), but the full-scan pin fallback (cat-file --batch-all-objects) includes dangling objects the reachable walk never classified. Subtracting the reachable-only withheld set therefore could not catch a private blob orphaned by a force-push/amend, which pinned to IPFS/Pinata in cleartext. Invert the default for blobs on the full-scan path: a candidate replicates only if it is a non-blob (commits/trees are structural) or a reachable, visibility-allowed blob; anything else (a dangling blob, or a withheld one) is dropped. The delta path keeps the cheaper reachable-only subtraction since delta candidates are always reachable, so no extra walk on the hot path. Adds list_all_objects_with_type/all_blob_oids (typed enumeration), replicable_blob_set + replicable_objects_fail_closed, and threads a full_scan flag through resolve_candidates_for_push. Pin functions now take a pre-filtered object list. Regression tests: dangling blob excluded end-to-end, fail-closed unit semantics, and the all-blob universe includes dangling + excludes non-blobs. * fix(node): gate repo-listing surfaces on visibility to stop private-repo enumeration (#97) list_repos, list_federated_repos, and the GraphQL repos query returned every repo row with no visibility check, so an unauthenticated caller could enumerate private repos' name, owner, description, and clone URL — and X-Total-Count leaked the private-repo count even when rows were filtered. Each surface now keeps a row only when visibility_check at "/" allows the caller, the same decision the per-repo content endpoints make — not a bare is_public filter, which would diverge for is_public=false-with-root-reader and is_public=true-with-root-deny. The "/" decision depends on owner short/full-DID matching and JSON reader-DID membership, so it is applied in Rust over an over-fetched set rather than pushed into SQL; the paged path derives X-Total-Count from the filtered set (dropping the SQL window count) and paginates after filtering, so neither the page nor the count leaks. Adds a batch root-rule query (list_visibility_rules_for_repos) to avoid N+1, and converts list_all_repos_paged into list_all_repos_deduped (mirror dedup preserved, pagination moved to Rust). Tests: anonymous hides private repo + count, owner sees own private repo, authorized root-reader sees an is_public=false repo (proving the visibility_check path, not is_public), and federated listing hides private from anonymous. * fix(review): share the listing visibility seam, extract fail-closed pin helper, add listing tests Code-review follow-up on the VIS-subtree fixes (no behavior change to the three shipped fixes; all confirmed sound by review): - DRY the '/' listing gate into one shared visibility::listable_at_root used by list_repos, list_federated_repos, and the GraphQL repos query, so the three surfaces enforce one rule instead of three drifting copies (enforce-shared-rule-on-every-reader-path). - Extract the full-scan fail-closed pin block into fail_closed_full_scan_objects, mirroring replication_withheld_set's degraded-path shape; drop the duplicated comment and the per-site no_rules sentinel (use &[]). - Add the three listing tests the review flagged as missing: anonymous GraphQL repos hides a private repo, the paged X-Total-Count path excludes the private count (the KTD2 exploit shape), and an is_public=true repo under a root deny is not listed (proves the gate is visibility_check, not a bare is_public filter). * fix(review): route the last inline root-visibility check through listable_at_root Round-2 review follow-up. replication_withheld_set still made the announce decision with an inline visibility_check(.., "/") == Allow — the fourth copy of the listing gate the seam refactor was meant to collapse. Route it through crate::visibility::listable_at_root (caller is always None, the anonymous replication audience), so every reader of the '/' decision now shares one seam. Also drop an em dash from the fail_closed_full_scan_objects doc comment. * fix(node): gate the /api/v1/stats repos count through visibility to close the count oracle (#104) The stats endpoint returned count_repos_deduped(): an unfiltered, unauthenticated count of every logical repo including is_public=false and mode-A hide-existence repos. A polling observer could detect that a hidden repo was created (N to N+1), the exact existence signal mode-A suppresses everywhere else. Count only the repos an anonymous caller could list: over-fetch the deduped set, batch-load visibility rules, and keep rows that pass listable_at_root(.., None), mirroring the listing seam in api/repos.rs. The caller is always None (meta_routes carries no auth layer). Fail closed: any DB error collapses the count to 0, since an under-count cannot leak existence. count_repos_deduped is retained as the tested reference for the DEDUP_CTE dedup count (its tests pin the CASE the live list paths share); it has no production caller after this change and is allowed dead outside tests. Tests: bare-private (no-rule branch), hide-existence mode-A with both repos is_public=true (the Some(rule) branch), public-under-root-deny, list-parity, sibling-fields-preserved, and empty-DB. * test(review): add listing visibility regression guards (#97) Three gaps the review flagged as uncovered: - owner-filtered listing (?owner=) still hides a private repo and its count from anonymous (a distinct SQL bind branch from the unfiltered path). - offset past the visible set returns an empty page while X-Total-Count stays the full visible total (guards against deriving the total from the cut page). - a logical repo present as both a canonical row (with a root-deny rule) and a gossip mirror row stays hidden: pins that the DEDUP_CTE picks the canonical survivor so the batch rule lookup finds the deny. If dedup ever picked the mirror (slash-form id, no rule) the gate would fall back to is_public and leak. * fix(review): address CodeRabbit feedback on #111 - visibility_pack NFC/NFD test: accept a SHA-1 or SHA-256 object id (40 | 64) instead of hardcoding 40, matching the fixture guard later in the file, so the test is not SHA-1-only. - federated listing test: also assert the response `count` reflects only the visible repos, guarding against a regression to the pre-filter total. - add an authenticated-caller GraphQL test: an owner sees their own private repo via `repos`, covering the resolver's caller-DID path that the anonymous-only test did not exercise. - stats: count via the no-stars `list_all_repos_deduped()` (same DEDUP_CTE) instead of the stars-joined variant, since the count never needs stars. * fix(review): match owner filter to did_matches for bare-owner mirror rows (#111) Addresses jatmn's P2. The shared listing CTE filtered owners with `owner_did = $1 OR owner_did LIKE '%:' || $1`, so a full `did:key:z...` query did not match a mirror-only row stored with the bare owner `z...`. #97 routed the no-limit owner-filtered path (used by `gl repo list`) through this SQL predicate, regressing the full-DID-against-bare-mirror direction that `crate::api::did_matches` handled in the old Rust path. Normalize the bound owner to its did:key short form and compare it against the row's owner key using the same CASE the dedup grouping already applies, so full and short forms match symmetrically and a non-key DID still matches only exactly. The no-arg `list_all_repos_deduped()` binds NULL and is unaffected. Regression test: a bare-owner mirror-only row is returned when queried by both the full `did🔑` form and the bare short form. Confirmed RED (test fails) before the fix. |
||
|
|
6ae316cc88 |
fix(node): gate fork_repo on per-caller path-scoped visibility (#98) (#109)
* fix(node): gate fork_repo on per-caller path-scoped visibility (#98) fork_repo authorized the caller only at the repo root "/" and then git clone --mirror'd the full source, discarding the visibility rules. A non-owner with root read but denied a path-scoped subtree could fork the full mirror and obtain blobs the filtered git_upload_pack path withholds. Refuse the fork when the caller has any withheld glob (per-caller withheld_globs non-empty), the same decision the read path makes; owners and authorized subtree readers are unaffected. Fail closed with a not-found response, before repo_store.acquire, so no withheld object is materialized. Add unit coverage for the policy and a direct-mount integration test pinning the gate wiring. * fix(review): note fork-gate glob-grammar dependency, import withheld_globs Address code-review findings on the #98 fork gate: - Document that fork_withheld_blocks's prefix-probe equivalence with the serve path depends on validate_path_glob keeping "/" the only whole-repo scope, mirroring has_path_scoped_rule's caveat (latent-coupling guard). - Import withheld_globs and call it unqualified, consistent with the sibling visibility_check import in the same module. |
||
|
|
6809201daa |
fix(node): anchor the real old_sha and issue a per-ref certificate (#72)
* fix(node): anchor the real old_sha and issue a per-ref certificate (#26) The push handler discarded the real old->new transition before anchoring and only certified the first ref of a multi-ref push, so the permanent Arweave record was a weaker, incomplete copy of facts the node had already parsed. - Thread the parsed old_sha through the clone moved into the anchor task instead of hardcoding a 64-zero placeholder. This also fixes the SHA width mismatch: object ids are 40-hex SHA-1, not 64-hex. - Issue a signed RefCertificate for every ref the push advanced, each carrying that ref's real old/new, rather than one cert for the first ref (which, on an empty push, signed a timestamp string as new_sha). Lock the anchor-body contract with a test asserting the upload carries the real old_sha and new_sha, never a placeholder. Scope is the integrity subset only. Carrying the signed certificate on Turbo, capturing the pusher signature, chaining certificates, and gl-side verification remain open under #26. Refs #26 * fix(node): thread real old_sha through every ref-update fan-out path PR #72 made the Arweave/DB anchor path per-ref with the real old_sha but left three sibling fan-out paths in git_receive_pack flattening multi-ref pushes and/or emitting a placeholder old_sha. Bring them in line: - gossip publish_ref_update now forwards the real old_sha (was ""), matching the RefUpdateEvent contract that documents all-zeros for a created ref - HTTP /sync/notify sends one signed request per ref with its real old_sha; the receiver is single-ref, so a multi-ref push no longer drops refs 2..N - GraphQL RefUpdateBroadcast emits one broadcast per ref with the real old_sha instead of only the first ref + a zero placeholder No receiver, struct, or wire-schema change; the all-zeros-for-created-ref convention is preserved on the wire. Addresses jatmn's CHANGES_REQUESTED and the two CodeRabbit findings. * test(node): regression-guard the per-ref sync-notify fan-out Extract the HTTP /sync/notify send into notify_peer_of_ref (one ref) and notify_peer_of_refs (one peer, looping refs), collapsing the deeply-nested inline block in git_receive_pack and giving the fan-out a unit-testable seam. The ref_name is now also logged on every warn arm. Add mockito regression tests: a two-ref push must emit one request per ref each carrying its real old_sha (guards against re-flattening to .first() with a zero placeholder, the bug PR #72 fixed), and a created ref must forward the all-zeros hash verbatim. Behavior is unchanged; this only adds the seam, the tests, and ref_name in the logs. * style(node): match sibling test conventions in repos tests Prefix the two new sync-notify tests with test_ and use the _mock binding convention (matching arweave.rs/pinata.rs). No behavior change. * style(node): fix rustfmt import order and dedupe the zero-sha literal - Reorder the repos test-module imports so `use crate::...` precedes the external `gitlawb_core` import (unblocks cargo fmt --check / CI). - Webhook `created` check reuses the existing ZERO_SHA constant instead of repeating the 40-hex literal. - Arweave test fixtures use the canonical 40-hex zero old_sha, matching the SHA-1 width the per-ref anchoring contract now carries. * perf(node): run peer notification after local broadcast and anchoring The post-push background task awaited the HTTP peer-notify fan-out before the GraphQL subscription broadcast and Arweave anchoring. With per-ref notification a slow or unreachable peer could delay both local subscribers and permanent anchoring by the sum of the peer round-trips. Move the peer-notify loop to run last; peers already learned of the push via the gossip publish earlier in the task, so this only deprioritizes the best-effort HTTP fallback. * style(node): correct notify-ordering comment and reuse ZERO_SHA in test Review follow-ups on the two prior commits: - The peer-notify ordering comment claimed peers 'already learned via the gossip publish', but that publish is gated on a present p2p handle; with no handle (the very case the HTTP fallback exists for) it does not run. State the actual rationale: peer-notify is the lowest-priority best-effort step, ordered last so it cannot delay local broadcast or anchoring. - The all-zeros notify test still inlined the 40-hex literal; point it at the ZERO_SHA constant like the production path. |
||
|
|
3e1e9045e4 |
fix(node): close under-withholding via full ref scope and full-history classification (#42) (#84)
* fix(node): close under-withholding via full ref scope and reachable-only pin set
blob_paths walked only refs/heads/* and refs/tags/* and skipped silently on a
failed git ls-tree, so a blob reachable only through another namespace, or a ref
that failed to traverse, could fall out of the withheld set and ship in cleartext.
Walk every ref and fail closed on traversal error.
The pin enumerators (ipfs_pin, pinata) used git cat-file --batch-all-objects,
which includes unreachable/dangling objects that have no path and cannot be
classified for withholding. Switch them to git rev-list --objects --all so the
pin set matches the reachable graph blob_paths evaluates.
* fix(node): include HEAD in the withheld-blob walk to close pin/serve asymmetry
blob_paths classified objects from for-each-ref only, but the pin set
(git rev-list --objects --all) and git upload-pack both also expose
objects reachable from HEAD. A detached HEAD (or any HEAD pointing
outside the ref-covered set) therefore put a blob into the pin/serve
path that the withholding walk never classified — a latent cleartext
leak of a withheld blob. Walk HEAD too (guarded for unborn HEAD on an
empty repo) so the withheld set is a superset of every exposure path.
Adds a regression test: a blob reachable only via a detached HEAD must
still be withheld (fails without this change).
* fix(node): classify blobs across full history, not just ref tips
blob_paths walked only ref-tip trees (git ls-tree -r <ref>), so a blob
that existed in an older commit and was deleted or rotated at the tip
was never classified, while the pin set (git rev-list --objects --all)
and upload-pack still expose it from history. That under-withheld the
blob into the IPFS/Pinata replication paths in cleartext.
Enumerate all reachable commits (git rev-list --all + HEAD for the
detached case) and walk each commit tree, deduping (oid, path) pairs.
ls-tree per commit is kept over rev-list --objects so every path a blob
lives at is still represented for the per-path visibility check. A ref
that resolves to a non-commit now fails closed explicitly, since
rev-list --all silently skips it where the old per-ref ls-tree errored.
Regression test: a secret blob deleted at the tip but reachable in
history is withheld.
* fix(node): share one history walk and fully peel ref types in withheld classifier
Addresses two review findings on the under-withholding classifier:
- withheld_blob_recipients walked blob_paths twice (once via
withheld_blob_oids, once directly). Extract withheld_from_pairs so a
single history walk feeds both the withheld set and the recipient map.
- The ref-type fail-closed guard used for-each-ref %(*objecttype), which
peels only one tag level and so misclassified a nested annotated tag of
a commit as a non-commit, refusing the whole walk (over-withhold). List
refnames and peel each fully with <ref>^{} through one cat-file
--batch-check; reap the child even if the stdin write fails. Fail closed
on a short read or any non-commit/missing target.
Adds tests: annotated + nested-tag of a commit does not fail closed,
annotated tag of a blob still fails closed, and a blob at both an allowed
and a denied path is not withheld.
* fix(node): drain cat-file stdout concurrently to close the peel deadlock
The ref-type guard peeled every <ref>^{} through git cat-file --batch-check,
writing all queries to stdin before draining stdout. On a repo with enough
output the child's stdout pipe fills, it stops reading stdin, and write_all
blocks forever — hanging every serve/pin that classifies the repo. Batching
the queries does NOT bound this: cat-file echoes the full query on a
'<query> missing' line, so output scales with refname length, and a few
hundred long-named dangling refs already exceed the pipe buffer.
Run the stdin write on a separate thread so the main thread drains stdout
concurrently via wait_with_output, which reaps the child unconditionally
before any error is surfaced; the writer is joined after the drain so it
cannot deadlock, and a vanished stdin pipe becomes a broken-pipe write error.
One pass, no batch bound, deadlock-free regardless of ref count or refname
length.
Extracted as assert_all_refs_are_commits with corrected doc attribution.
Detect the missing-object line by its trailing status word. Adds a
timeout-bounded regression test with 500 long-named dangling refs (>64 KiB of
cat-file output) that fails rather than hangs if the deadlock returns.
* fix(node): parse ls-tree -z raw paths and fail closed on non-UTF-8 to stop under-withholding
Plain `git ls-tree -r` C-quotes any path with non-ASCII or special bytes
(café.txt becomes "secret/caf\303\251.txt"), so the stored path no longer
matched a visibility rule like /secret/** and the blob was served/pinned in
cleartext. Switch blob_paths to `git ls-tree -rz` and split the NUL-delimited
records, which emit paths raw; the TAB field separator survives -z so the
per-record parse is otherwise unchanged.
Parse strictly with str::from_utf8 and fail closed on invalid UTF-8 rather than
from_utf8_lossy: a lossy decode would rewrite an invalid byte in a denied
directory name to U+FFFD, and the mangled path would again miss its deny rule
(the same under-withholding class one layer down).
Tests: a non-ASCII path stays withheld (and the public blob still replicates),
and a non-UTF-8 path fails closed.
|
||
|
|
1960f6b28f |
ci(triage): label fork PRs and stop auto-closing PRs that only lack an issue link (#107)
* ci(triage): run PR triage on pull_request_target so fork PRs can be labeled Fork PRs run the triage job with a read-only GITHUB_TOKEN under the pull_request event regardless of the workflow's declared write permissions, so addLabels/createComment fail with 403 'Resource not accessible by integration' for every external contributor (e.g. #103). Switch the trigger to pull_request_target, which runs in the base-repo context with the declared write token. The job never checks out or runs PR head code (it only reads context.payload and the pulls/files API and manages labels), so it carries none of the untrusted-code-execution risk that makes pull_request_target dangerous. * ci(stale): drop needs-issue from auto-close trigger, keep it advisory needs-issue flags PRs that link no issue, but linking one is a SHOULD in CONTRIBUTING (required only for protocol-level changes), and many legitimate small PRs (docs, obvious fixes) have none. Coupling it to the 14/21-day auto-close was overreach: a substantive, well-described PR could be closed purely for missing an issue reference. Keep needs-issue as an advisory label and guidance nudge; leave only needs-description (an empty/thin body, a far stronger low-effort signal) as the auto-close trigger. * ci(stale): align stale message with description-only close condition The auto-close trigger narrowed to needs-description, but the stale message still told contributors a PR could close for a missing linked issue. Drop that clause so the warning matches what actually closes a PR. * style(ci): replace em dashes in triage/stale contributor messages * docs(contributing): match stale close policy to description-only trigger Auto-close is driven by needs-description only; a missing issue link stays advisory. Align the contributor docs with the workflow so the OR wording no longer implies a missing issue link can close a PR. |
||
|
|
abc9ad03ac |
fix(node): fail closed when a recipient DID can't be resolved (#47) (#67)
* fix(node): fail closed when a recipient DID can't be resolved (#47) encrypt_and_pin resolved recipient DIDs with filter_map, silently dropping any that failed to resolve and sealing the envelope to the remaining subset while still recording the full intended recipient set as covered. A reader whose DID could not be resolved at seal time (any did:web/did:gitlawb, or a malformed did:key) was then permanently locked out: the dedup compares the recorded full set against the desired set, matches, and never re-seals. Resolve all recipient DIDs up front via resolve_all_recipients; if any fail, log and skip the blob rather than seal to a partial set. Nothing partial is recorded, so the dedup stays honest and the blob re-seals on a later push once resolution is available. Add a bounded per-blob warn plus one aggregate coverage warn so a fully-migrated did:gitlawb org (zero encrypted-pin coverage) is one greppable line, not a scrape, and give the previously silent read_object and pin_git_object failure arms warn logs. * test(node): extract plan_seal so the #47 fail-closed gate is unit-tested The fail-closed invariant lived only in resolve_all_recipients, tested in isolation; nothing proved encrypt_and_pin acts on it (a refactor falling through to a partial seal would pass every test). encrypt_and_pin needs a live Postgres Db, which CI does not provide, so it can't be tested directly. Extract the per-blob seal/skip decision into a pure plan_seal() -> SealPlan (no DB, no IO), behaviour-preserving, and unit-test its branches: seals only when all recipients resolve, fails closed on any unresolvable DID, skips the empty set, skips an unchanged tag, re-seals a changed set. * style(node): rustfmt encrypted_pin (Seal variant + assert_eq wrap) |
||
|
|
338ff83584 |
fix(node): skip withheld-walk when no path-scoped rule can withhold (#60)
* feat(node): add has_path_scoped_rule predicate for withheld-walk short-circuit Shared predicate (rules.iter().any(path_glob != "/")) both serve and replication paths will use to skip the per-blob withheld walk when no rule can withhold. Doc records the "/"-gate precondition and the validate_path_glob dependency. Adds unit tests plus a gate-aware safety invariant test. * fix(node): skip withheld-walk when no path-scoped rule can withhold (#50) The serve path (git_upload_pack) ran the full per-ref withheld_blob_oids walk on every clone/fetch before falling back to a plain upload_pack, even for repos with no path-scoped rules where the walk can never withhold anything. Cost grew with #42 (per-ref git ls-tree -r). Guard both call sites on has_path_scoped_rule: the serve path skips the spawn_blocking walk entirely and serves upload_pack directly; the replication path generalizes its is_empty() short-circuit to also cover root-only rule sets. Safe because the whole-repo "/" gate runs before both sites, so a denying root rule 404s/withholds upstream and never reaches the predicate. No change to served-pack contents on the success path. * fix(node): gate encrypt-then-pin walk on has_path_scoped_rule (#50) The post-push encrypt-then-pin block in git_receive_pack gated the withheld_blob_recipients call on `!r.is_empty()`, which is true for a root-only `/` rule set. withheld_blob_recipients then runs the same per-ref `git ls-tree` walk (withheld_blob_oids) the short-circuit is meant to avoid, and returns an empty recipient map anyway. Gate it on has_path_scoped_rule to match the serve and replication sites; the skip is behavior-preserving (empty map -> encrypt_and_pin is a no-op). Also align the two remaining call sites to the visibility_pack import alias for consistency. * test(node): cover the serve decision across both has_path_scoped_rule branches Drives the git_upload_pack serve decision over a real bare repo for the INV-2 caller (a reader allowed at whole-repo "/" but denied a path-scoped subtree), exercising both branches the predicate selects: root-only rules skip the walk and serve the full pack (asserting the walk would withhold nothing, so the skip is sound), and a /secret/** rule runs the walk and excludes the secret blob from the served set while keeping the public one. |
||
|
|
636d3abe17 |
chore(deps): bump tar to 0.4.46 and rand to 0.9.3 to clear advisories (#105)
tar 0.4.45 had a PAX header desynchronization issue (GHSA-3pv8-6f4r-ffg2, fixed in 0.4.46); rand 0.9.2 was unsound with a custom logger using rand::rng() (GHSA-cq8v-f236-94qc, fixed in 0.9.3). Both resolve as lockfile-only bumps with no manifest change. Build and cargo audit pass. lru 0.12.5 (GHSA-rhfx-m35p-ff5j) stays pinned by aws-sdk-s3 (lru ^0.12.2) and the hickory advisories remain accepted risk per .cargo/audit.toml (#76). |
||
|
|
2f6611a30f |
fix(core): zeroize the derived X25519 secret (#65) (#91)
x25519_secret_from_seed derived the X25519 private scalar from the Ed25519 seed and returned it as a bare [u8; 32]. Because [u8; 32] is Copy with no Drop (and sha2's digest output likewise has no zeroize), the scalar, the SHA-512 digest, and the returned temporary were released without being scrubbed, leaving secret-derived material in freed memory. This is the same Copy-no-Drop class fixed for the raw seed in #41, on the derived secret. Return Zeroizing<[u8; 32]>, build the scalar directly into a zeroizing buffer so no bare secret local persists, and explicitly wipe the SHA-512 digest before it drops. Callers deref the result into crypto_box::SecretKey, which already scrubs its own copy. No behavior change: the derived scalar and all decryption output are byte-identical, covered by the existing ed25519_to_x25519_keypair_agrees and seal_open_round_trip_for_recipients tests. |
||
|
|
3e8e333aa0 |
fix(node): dedupe mirror and canonical repo rows on list surfaces (#6) (#73)
* fix(node): dedupe mirror and canonical repo rows on list surfaces (#6) The non-paged GET /api/v1/repos legacy path and list_federated_repos returned both the short-owner peer mirror row and the canonical did:key row for the same logical repo, so profiles rendered the repo twice. Only the paged path collapsed them, in SQL. Add a dedupe_canonical_repos helper that groups by (normalized owner, name), keeps the canonical non-mirror row (tie broken by earliest created_at), and carries the group's latest updated_at onto the survivor, matching the paged SQL dedup. Apply it at both non-paged surfaces and cover it with unit tests. * fix(node): dedupe GraphQL + stats repo surfaces and key mirror detection on the repo id Addresses jatmn's review on #73 (dedup not applied on every reader path; mirror detection keyed on a user-settable string). - GraphQL repos and /api/v1/stats now collapse mirror+canonical rows, via new Db::list_all_repos_deduped and count_repos_deduped that share the DISTINCT ON CTE with list_all_repos_paged so the dedup rule cannot drift. - Mirror detection keys on the structural slash-form id (written only by upsert_mirror_repo) instead of the description == 'mirrored from peer' string, in both the SQL paths and dedupe_canonical_repos. - Deterministic survivor on a full created_at tie (id ASC) in both implementations. - Legacy REST list and federated keep their method-scoped did_matches owner filter in Rust; it does not compose with the method-blind SQL group key, so those paths intentionally stay on the Rust helper. - Adds sqlx and unit tests for the new surfaces, the structural marker, and the tiebreak. * test(node): cover mirror-only, empty, and count/list-consistency dedup cases Follow-ups from code review on the dedup change: - list_all_repos_deduped/count_repos_deduped: mirror-only group survives, empty table returns empty/0, and count_repos_deduped equals the deduped list length (guards the two independent SQL queries against grouping-key drift). - Document list_all_repos as the raw, non-deduped enumeration path (object lookup only), so it is not mistaken for a listing-surface method. * style(node): apply cargo fmt to dedup test code * fix(node): make the repo dedup key did:key-aware so distinct DID methods don't collapse The dedup grouping key took the last ':' segment of owner_did (split_part / rsplit), so two repos owned by did:key:X and did:gitlawb:X with the same name collapsed into one logical repo on the list, paged, count, stats, and GraphQL surfaces. That is the exact cross-method collision the codebase already guards against in did_matches. Replace it with a did:key-aware key that strips a did🔑 prefix only when the remainder is a bare id (no ':'), otherwise keeps the full DID, reproducing did_matches/key_id as an equivalence relation: did:key:X and a bare mirror X still collapse, while distinct methods never merge. Applied byte-identically across DEDUP_CTE (DISTINCT ON / PARTITION BY / ORDER BY), count_repos_deduped, the empty-page count fallback, and the in-memory dedupe_canonical_repos, so the SQL and Rust paths agree. The backing index lived in the already-released migration v1, so v1 is left untouched and a new migration v7 drops idx_repos_owner_short_name and builds idx_repos_owner_key_name on the matching expression; the CASE must stay byte-identical to the queries or Postgres stops using it. Tests cover both the in-memory and SQL paths: distinct methods stay separate, bare-id and did:key forms collapse, and the residual-colon guard keeps a malformed did:key:did:gitlawb:X distinct from the bare method DID. 216 pass. |
||
|
|
f28fa02236 |
fix(security): gate webhook creation through the public-host validator (#81) (#92)
Webhook creation only scheme-checked the URL, so an authenticated repo owner could register a hook pointed at loopback, private, or metadata endpoints and make the node POST to them on repo events. Route the URL through is_public_http_url, the same validator the peer announce path already uses. Wiring it up surfaced a bypass in that shared validator: it folded IPv4-mapped and compatible IPv6 but not 6to4 or NAT64, so [2002:7f00:1::] (127.0.0.1) slipped through. Fold 6to4 and the NAT64 well-known prefix, reject the NAT64 local-use prefix and the rest of 64:ff9b::/32 outright (the embedded v4 offset varies by prefix length, so a decode would be error-prone), and reject 0.0.0.0/8. The change only ever rejects more, so the peer announce handler, the DB-boundary writer, and the boot-time prune inherit the same hardening. Closes #81. |
||
|
|
1af4fdf485 |
feat(node): pin the per-push object delta instead of re-enumerating the whole repo (#90)
* feat(node): add push-delta object enumeration module
Compute the per-push object delta (git rev-list --objects <new> --not <old>)
for the pin path instead of enumerating the whole repo, with a fully-peeled
ref-type guard (cat-file -t '<sha>^{}') that fails closed to a full scan on a
non-commit tip, any git error, or the GITLAWB_PIN_FORCE_FULLSCAN kill-switch.
The whole-repo lister moves here for the fallback and the reconciliation sweep.
Pin-enumeration scope only (N16); the withheld filter is unchanged.
* feat(node): pin the per-push object delta instead of the whole repo
Wire the push-delta enumeration into the IPFS and Pinata pin paths so pin work
scales with push size, not repo size (N16).
- U2: both pin_new_objects twins take a candidate OID set instead of scanning
the whole repo internally; repo_path is retained for reading object bytes.
- U4: extract the anonymous replication withheld gate (caller=None, no caller
param) into a shared helper returning (announce, withheld), so the push path
and the future reconciliation sweep cannot drift. The per-caller read-serve
withheld call in git_upload_pack is deliberately left separate.
- U3: the post-receive handler resolves the push delta once (off-thread),
falls back to a full scan on a non-commit tip / git error / kill-switch, and
passes the candidate set to both pin paths. Logs delta size and fallbacks.
The reconciliation sweep (U5) is deferred: it depends on the history-deep
blob_paths fix (#84) landing on main first.
* fix(review): log degraded pin-candidate paths, extract wiring to push_delta
Code review found the push candidate computation collapsed both a failed
full-scan fallback and a panicked spawn_blocking task to a silent empty set,
diverging from the sibling withheld path that logs and fails closed.
Extract resolve_candidates_for_push into git::push_delta (it owns the
PinCandidates dispatch and full-scan fallback), logging every degraded path
(fallback taken, fallback failed, task panicked) instead of failing silently.
Pinning nothing on a hard failure is still safe (the withheld filter always
runs; the reconciliation sweep backstops durability) but is now observable.
Moving the dispatch out of the HTTP handler also makes it unit-testable.
Add tests: tag-of-tag-of-commit peels to a Delta, and resolve_candidates_for_push
delta + non-commit-tip full-scan-fallback paths.
* fix(node): fail announce closed when withheld walk fails (#90)
A failed or panicked withheld_blob_oids walk left announce=true while
withheld=None, so a push we could not vet would still gossip ref updates,
notify peers, and write a permanent Arweave anchor. Force announce false
in that arm so the gate fails closed on both axes.
|
||
|
|
3d880cd56f |
fix(node): reject malformed path globs with non-trailing or empty-segment wildcards (#74) (#75)
validate_path_glob checked `ends_with("/**")` without first stripping it, so any
glob that still ended in `/**` slipped past the wildcard guard even when it held a
`*` elsewhere in the prefix (e.g. `/a*b/**`, `/a/**/**`). The prefix is fed to
glob_prefix/glob_matches as a literal, so such a rule matched no real path and
silently granted nobody, the exact silent-misconfiguration this validator exists
to prevent.
Strip the one permitted trailing `/**` first, then reject any remaining `*`.
While here, close a related hole in the same validator: `glob_prefix` greedily
trims all trailing `**` and `/`, so `//**` collapsed to `/` (whole repo) and
bypassed the explicit `/**` guard, and `//secret/**` became a dead rule whose
prefix never matches a real single-slash path. Reject empty path segments so the
validator and glob_prefix agree on the canonical form.
Adds `/a*b/**`, `/a/**/**`, `/**/**`, `//**`, `//secret/**` to the rejection test.
|
||
|
|
0a15e763d2 |
feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31) (#68)
* feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31) A valid did:key signature on git-receive-pack is authentication, not authorization: any party can generate a key, derive its DID, sign, and push to an unprotected branch. Owner checks only ran on protected branches, so a non-owner could write to unprotected branches of an arbitrary repo. Add an opt-in flag GITLAWB_ENFORCE_OWNER_PUSH (default false, mirroring GITLAWB_REQUIRE_SIGNED_PEER_WRITES) that requires the authenticated pusher to be the repo owner on every branch. Default-off keeps live nodes unaffected. Design follows the maintainer's review on #31: - Authorize on the canonical AuthenticatedDid injected by require_signature, via Extension<AuthenticatedDid> on the handler — not a re-parse of the headers. The legacy branch-protection check is left as-is. - Fail closed: an absent identity or a non-owner caller is rejected. The policy lives in a pure fn (owner_push_rejection) so it is unit-tested without a DB. - The gate runs before branch protection, so one rejection yields one error body. - Factor the duplicated owner-match into auth::is_repo_owner, reused by receive-pack, protect.rs, and visibility.rs (each keeps its own message). events.rs / peers.rs are left out — they aren't owner checks. - Name the push gate by intent (caller_authorized_to_push) so the planned Phase 2 UCAN git/push capability is a pure addition, not a rewrite. Tests: owner (full + short DID) allowed, non-owner rejected, missing-DID rejected, flag-off legacy. Docs: .env.example + docs/RUN-A-NODE.md. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(node): address review on #68 — 403 status, finish owner-match consolidation Per @beardthelion's review: - Return 403 Forbidden (not 400) for authorization denials: the owner-push gate, the branch-protection denial, and the owner checks in protect.rs / visibility.rs. 400 reads as a malformed request and some git/CI clients retry it. - Finish the is_repo_owner consolidation: route the branch-protection check and the repos list filter through the helper. The branch-protection check now uses the verified AuthenticatedDid (auth.0) instead of re-parsing the headers. - Drop the second identity source on the push path entirely: the post-push event recording also uses auth.0, so extract_did_from_auth (and the handler's HeaderMap param + import) are removed. - Tests assert the Forbidden variant (so a future status regression fails loudly) and add direct coverage for is_repo_owner / caller_authorized_to_push. - Docs: warn that enabling the flag blocks delegated and CI agents until the Phase 2 UCAN git/push capability is honored. Co-Authored-By: Claude Opus 4.8 <[email protected]> --------- Co-authored-by: FrozenRaspberry <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> |
||
|
|
2153b0b7a6 |
fix(gl): paginate gl-clone Arweave recovery and make /encrypted-blobs parsing schema-strict (#49) (#70)
* fix(gl): make /encrypted-blobs recovery parsing schema-strict Deserialize the /encrypted-blobs 200 body into a typed EncryptedBlobsResponse instead of poking at a serde_json::Value and defaulting blobs to [] via unwrap_or_default(). A server schema regression now fails closed (a hard parse error) rather than silently yielding nothing to recover, mirroring the existing WithheldPathsResponse convention. The sole caller still does not abort the clone but now surfaces the error instead of swallowing it into an empty result. Part of #49. * fix(gl): paginate Arweave manifest discovery and rank ties by block height Discovery hard-coded a single first:100 GraphQL query with no pagination, so a repo with more than 100 encrypted-manifest anchors (one per push) silently lost recovery coverage for any blob whose most recent seal fell outside that window. Follow cursor pagination (after + pageInfo) until coverage is complete, deduping transaction ids and bounding the loop (1000 pages / 10000 ids, degenerate-cursor guard) so a hostile or buggy gateway cannot grow the downstream per-id fetch loop without limit. A full page returned without pageInfo, a cap hit, or a failure after discovery began now warns; a page-1 failure (unconfigured/unreachable gateway) stays silent as before. Pagination concatenates pages in gateway order, which made merge_manifests' equal-timestamp tie-break (previously insertion-order, i.e. accidentally newest on single-page HEIGHT_DESC) gateway-dependent. Rank candidates by the composite key (timestamp, Arweave block height, cid): timestamp primary keeps the existing distinct-timestamp latest-wins; block height breaks equal-timestamp ties by anchor recency (a not-yet-mined anchor counts as newest), preserving the pre-pagination behavior; cid is a final deterministic tiebreak for same-block anchors. Height is parsed defensively so a present-but-unreadable height (e.g. a gateway stringifying large ints) cannot masquerade as a pending/newest anchor. Part of #49. * fix(review): bound edges per Arweave page and cover degenerate-cursor/dedup Code-review follow-ups on the #49 recovery change: - Cap edges parsed per GraphQL page at ARWEAVE_PAGE_SIZE so a hostile gateway returning one oversized page cannot defeat the MAX_TX_IDS budget or flood the downstream per-id manifest fetch loop (adversarial review). - Collapse the two identical gateway-failure arms in the discovery loop into a single warn site, and hoist PAGE_SIZE to a named const tied to the query's first:100 (maintainability). - Add tests: per-page edge bound, non-advancing-cursor termination (infinite-loop guard), and cross-page tx-id dedup fetched exactly once (testing). Part of #49. * fix(review): make the degenerate-cursor test assert, and pin the page-size invariant Round-2 review follow-ups: - The non-advancing-cursor test bound its mock to a _-prefixed handle with expect_at_most(3) and never asserted it; mockito defaults assert_on_drop=false, so the test passed even if the guard were removed. Bind it, tighten to expect(2) (the guard breaks on the second page, so discovery makes exactly two POSTs), and assert_async. - Correct the parse_tx_page docstring: the per-page take() bounds the TxRef vec and fetch loop, not the response-body deserialization (an unbounded-body concern shared by every gateway read here). - Hoist the discovery query to a const and add query_first_matches_page_size so the query first:N literal and ARWEAVE_PAGE_SIZE are kept equal by a test rather than a comment. Part of #49. |
||
|
|
96fcfdb1ca |
fix(node): preserve promisor mirror mode on unknown withheld-paths lookup (#48) (#69)
Peer sync decided mirror mode from the origin's withheld-paths answer via classify_mirror, whose _ => Plain arm collapsed both Some(empty) (genuinely public) and None (lookup 404'd / 5xx / network / parse error) into Plain. For an existing promisor mirror that meant fetch_repo(.., Plain) unset remote.origin.promisor + partialclonefilter and ran git fetch --refetch, so a transient withheld-paths outage downgraded a still-withheld mirror to a full clone and broke syncs until the endpoint recovered. Replace classify_mirror with resolve_mirror_mode(withheld, exists, promisor): only Some(empty) downgrades an existing mirror; None preserves the on-disk promisor mode. A genuine public transition always returns Some(empty), never None, so preserving on None cannot mask one; it only suppresses the transient-error false positive. Add a three-valued PromisorProbe read from git config --get exit codes so a transient probe failure (Unknown) is not mistaken for a definitive NotPromisor and cannot itself trigger the downgrade. One warn marks the preserve path, derived from the resolved mode so it cannot drift. Tests cover every resolve_mirror_mode arm (incl. the regression and the indeterminate-probe case) and the probe's Promisor / NotPromisor / Unknown outcomes against real git repos. |
||
|
|
c9f43b0105 |
fix(core): route seed access through the zeroizing wrapper (#41) (#64)
* fix(core): route seed access through the zeroizing wrapper (#41) Remove Keypair::seed_bytes(), which returned a bare [u8; 32] copy of the Ed25519 private seed. Because [u8; 32] is Copy with no Drop, each caller's copy was released without being scrubbed, leaving secret key material in freed memory. The zeroizing accessor to_seed() -> Zeroizing<[u8; 32]> already existed beside it; this makes it the sole seed accessor and migrates the two in-crate callers (envelope open path and a crypto test) to it via deref coercion. No behavior change to envelope encryption: the derived X25519 secret is byte-identical, covered by the existing round-trip tests. Adds an identity test pinning to_seed() as the only seed accessor. * test(core): pin to_seed's Zeroizing return type, drop duplicate round-trip The to_seed_is_sole_seed_accessor test was named for a compile-time property its body couldn't assert, and its from_seed -> DID check duplicated from_seed_round_trip. Replace it with a type-annotated binding that fails to compile if to_seed ever returns a bare [u8; 32] again, plus the determinism check, which is the only behavior it uniquely covered. * fix(node): migrate node_seed pin caller to to_seed() main grew a third seed_bytes() caller (repos.rs, from #40) after this branch was cut. Route it through to_seed() too: node_seed is moved into a long-lived tokio::spawn pin task, so the Zeroizing wrapper scrubs the copy when that task ends. encrypt_and_pin takes &[u8; 32]; the wrapper deref-coerces, so the call site is unchanged. |
||
|
|
97d37fd541 |
Perf/alloc and copy cleanups (#80)
* perf: trim allocations and redundant copies on hot paths - Cargo.toml: enable thin LTO + symbol stripping for release builds - git-remote-gitlawb: attach pack body after signing to move (not clone) potentially large pack bytes, halving peak push memory - gitlawb-attest: deserialize payload by reference instead of cloning it - core/http_sig: compare components by &str to avoid per-item String alloc - node/tigris: drop redundant to_vec() copy of the response body - node/rate_limit: only allocate the key String on first insert, not per request - node/sync: resolve the peer table once per batch instead of per item Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * perf(sync): skip peer lookup on empty sync batch Return early from process_batch when dequeue_pending_syncs yields nothing — the common case on the 30s idle poll — so an empty batch no longer fires an unnecessary list_peers() DB query (CodeRabbit, PR #80). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * test(sync): cover per-batch peer origin-URL resolution Extract the peer -> origin-URL lookup from process_batch into a pure resolve_origin_url() helper and unit-test it (match + trailing-slash trim, unknown DID, empty peer list). This is the per-batch resolution PR #80 introduced; pulling it out of the DB-bound process_batch lets it be tested without Postgres. No behavior change. Addresses the needs-tests label. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(tigris): use a unique temp dir per archive extraction The fixed `.{file_name}.tmp-extract` path let two concurrent extractions of the same repo share one temp dir, so one's remove_dir_all/unpack could clobber the other's in-progress work. Suffix the temp dir with a fresh UUID so each extraction is isolated; this also removes the need to pre-clear a leftover dir (a unique name can't collide with an interrupted run). The on-failure cleanup still removes our own dir, and success consumes it via rename (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(tigris): serialize repo publish per path to close the swap race Unique temp dirs let extractions unpack in parallel, but the final remove_dir_all + rename swap could still interleave for the same local_path, causing a nondeterministic overwrite or a rename-onto-non-empty failure. Guard just the swap with a process-wide mutex keyed by local_path so only one extraction publishes per repo at a time; the expensive unpack stays parallel (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * docs(tigris): restore decompress_repo doc comment ordering The earlier publish_lock insertion landed between decompress_repo's doc comment and its fn, gluing the two doc blocks onto publish_lock and leaving decompress_repo undocumented. Reorder so each fn keeps its own doc. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * docs(tigris): note publish_lock map is never evicted Document the known unbounded-growth limitation of the per-path LOCKS map: bounded by the repo count so negligible for normal use, but high-volume deployments may want LRU/weak-ref eviction. No behavior change (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
a8cc33a185 |
fix(security): reject non-public peer URLs + prune poisoned peers (#78)
* fix(security): reject non-public peer URLs + prune poisoned peers The unauthenticated POST /api/v1/peers/announce only validated the http(s) scheme, so anyone could register loopback/private 'peers' (localhost:5432, localhost:22, internal admin URLs). On every push the node fans out signed sync-notify POSTs to all peers — turning this into an SSRF probe and burying the real peers under junk so node-origin repos stopped replicating. Observed on node.gitlawb.com: 126/194 peer rows were injected junk (fake DIDs z6MkScan…/z6MkAdmin…, localhost targets). - is_public_http_url(): reject non-http(s), loopback, unspecified, private (RFC1918 + ULA), link-local, and localhost/.local/.internal hosts. Applied at announce time. - db.prune_non_public_peers(): boot-time sweep removing already-injected rows, run from main() alongside prune_self_peers — self-heals poisoned tables on deploy. - tests for the validator (accept public, reject loopback/private/internal, incl. IPv6 [::1]). Co-Authored-By: OpenClaude <[email protected]> * fix(security): address PR #78 review — build, redirect SSRF, validation gaps Resolves the P1/P2 review feedback on the peer-announce SSRF fix: - main.rs: revert the unrelated, incomplete `storage::build()` refactor back to the tigris init from main (the `storage` module never existed, breaking the build). Keeps only the prune_non_public_peers boot call. - Redirect bypass: set redirect::Policy::none() on all peer-facing reqwest clients (shared http_client, sync worker, ping_peer) so a peer cannot answer `302 -> http://127.0.0.1/` and bypass the public-URL check. ping_peer now reuses the shared no-redirect client instead of bare reqwest::get. - Move the public-URL guard into db::upsert_peer so the bootstrap announce-back path (main.rs) can no longer re-poison the table after the prune. - is_public_http_url: strip trailing-dot FQDNs (`localhost.`), fold IPv4-mapped/compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`) to IPv4 before range checks, and reject CGNAT (100.64.0.0/10). Added tests for each. - prune_non_public_peers: collect bad DIDs then DELETE ... WHERE did = ANY($1) in one statement, so a transient error no longer abandons remaining rows. Follow-up (separate issue): DNS-resolution rebinding guard rejecting peer hostnames that resolve to private IPs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(security): satisfy rustfmt + don't drop redirect guard on builder failure - peers.rs: collapse the CGNAT/private check onto one line (cargo fmt --check). - sync.rs: replace the unwrap_or_else(reqwest::Client::new()) fallback with .expect(). The default client follows redirects, so the fallback silently reintroduced the SSRF vector Policy::none() exists to close (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: OpenClaude <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
2202b0097f |
feat(node): enforce per-route authorization across the REST and GraphQL surface (#87)
* test(node): add HTTP-API integration harness with CI Postgres
Adds a #[cfg(test)] test-support module: a migrated AppState over a real
#[sqlx::test] pool (test_state), a DB-free variant (test_state_lazy), the
assembled router (app), and signed_request_as for injecting an authenticated
DID without RFC-9421 signing. A test-callable run_migrations reuses the
production migrate() path, and sqlx gains the macros/migrate features so
#[sqlx::test] is available. CI's test job gets a postgres service behind a
pg_isready health gate so the DB-backed tests can run.
The proving test exercises the owner gate on PUT /visibility end to end:
non-owner is rejected with 400, owner succeeds. signed_request_as sets a JSON
content-type so the Json extractor does not 415 before the handler runs.
* feat(node): enforce per-route authorization on REST mutations
Closes the missing-authorization cluster on the signed REST surface, where
'authenticated' meant 'any participant' because identities are permissionless.
Adds two shared helpers in api/mod.rs: require_repo_owner (403) and did_matches,
a method-safe DID comparison that collapses the full did:key vs bare short form
without matching across methods (did:web/did:gitlawb share the base58 space).
Per-route gates:
- merge_pr is owner-only (subsumes branch protection); close_pr and close_issue
allow the repo owner or the PR/issue author (the issue author is read from the
git-JSON blob, with a None author falling back to owner-only).
- create_review, create_comment, create_issue_comment, and create_bounty are
read-gated (participants need read access, not ownership; this also closes the
private-repo bounty leak).
- the task handlers bind the acting DID to the authenticated signer.
- dispute_bounty, previously ungated, now requires the creator or claimant; the
other bounty and replica identity checks switch from raw equality to did_matches.
A source-level guard test asserts every in-scope mutation handler still carries
its expected gate marker, so a removed gate or an unclassified new route fails
CI. DB-backed tests (via the integration harness) cover merge owner-only and the
task signer-binding; did_matches has unit coverage for the cross-method case.
* feat(node): authenticate GraphQL mutations
GraphQL mutations were a fully unauthenticated parallel write path to the task
system (N2): the /graphql route had no auth layer and the resolvers took the
acting DID as plain arguments. Apply optional_signature to the /graphql POST so
a verified DID is attached when a signature is present (queries stay open), and
thread it into request-scoped data. Each mutation now reads the verified signer
via require_signer and binds the acting DID to it (rejecting an unsigned request
or one whose claimed delegator/assignee/by_did differs from the signer), reusing
did_matches for the comparison. Read-only subscriptions (/graphql/ws) are left
open. A resolver-level test covers the unsigned, mismatched, and matching cases.
* fix(node): path-scope the get_tree visibility gate
get_tree gated on the repo root ("/") regardless of the requested subtree, so
a caller denied a withheld subtree could still enumerate its filenames and blob
SHAs (N3). Gate on the requested path instead, mirroring get_blob, and reject
traversal segments. A cross-caller test shows the rejection is path-scoped: a
non-reader is denied the withheld subtree but passes the gate on a non-withheld
path.
* fix(node): authorize task completion and read-gate PR/issue creation
Two authorization gaps surfaced by an adversarial review of the auth stack,
both necessary-but-insufficient siblings of fixes already in it.
complete_task/fail_task bound the acting DID to the signer but never checked
the caller against the task's assignee, and finish_task updated by id alone, so
any authenticated did:key could finish or fail any task in any state. Load the
task and require the caller to be its assignee (REST and GraphQL), and add an
'AND status=claimed' predicate so only a claimed task transitions. The
now-unused by_did request fields are removed.
create_pr/create_issue resolved the repo with get_repo but skipped
authorize_repo_read, so a non-reader could open a PR (firing the owner's
webhooks) or file an issue against a private repo they cannot read. Gate both
on read access like their create_review/create_comment/create_bounty siblings.
Adds DB-backed tests covering non-assignee rejection (including the empty-body
bypass), the claimed-state predicate, and private-repo PR/issue denial.
* test(node): harden the authz drift-guard against false passes
The guard asserted a marker STRING appeared in each handler body, which a bare
identifier in a comment or log line could satisfy even after the real gate was
deleted, and its body slice over-ran on any handler not declared exactly
'pub async fn'.
- Markers are now gate-shaped: a call (require_repo_owner(, did_matches(,
authorize_repo_read() or a binding/comparison expression
(caller != &record.owner_did, let owner_did = auth.0), never a field name.
- Full-line comments are stripped before matching, so a gate that survives only
as a comment no longer counts as enforced.
- The body slice bounds at the next top-level fn item across pub async,
pub(crate) async, async, pub, and bare fn forms.
- Adds rows for the now-read-gated create_pr/create_issue and for fork_repo,
create_repo, set_profile, claim_bounty; register is excluded with a note (it
trusts the body did, tracked as P3 D3-1).
- Adds a self-test proving a comment-only marker does not satisfy a row.
* fix(node): P3 consistency batch from the adversarial review
Five low-severity items from the stack review. The sixth (a did_matches
false-negative on non-canonical multibase encodings of the same key) is left
as-is: it is a self-inflicted owner lockout unreachable with the conforming
client, never an impersonation vector, so decoding keys in the auth path is not
warranted.
- register bound the registered DID to the request body, not the signer, so a
signed caller could create or refresh a trust row under a victim DID. Bind it
to auth.0 (403 on mismatch) and add a drift-guard row now that it is gated.
- Bounty submit/approve/cancel returned 400 for an identity-mismatch denial
where dispute returned 403. Return Forbidden, matching the rest of the surface.
- The visibility/protect owner gates (require_owner and the protect.rs inline
checks) returned 400 for a non-owner; return 403 like require_repo_owner, and
assert the exact code in the harness.
- get_tree rejected ./.. segments but not empty interior segments, so a path
like secret//x could reach git while the gate saw a different string. Reject
empty interior segments too (the empty path remains the root listing).
- Tighten the get_tree path-scope test to assert an exact 200 on the
non-withheld branch so a future upstream 4xx/5xx cannot masquerade as
gate-pass.
Adds a register-binding regression test.
* fix(node): unify protect/visibility owner checks on did_matches
CodeRabbit flagged that protect_branch/unprotect_branch and visibility's
require_owner used a trailing-segment owner compare (split(':').next_back())
distinct from the hardened did_matches, so the two owner-match idioms could
drift. It is not exploitable (the authenticated caller is always the full did:
form while the short form is a bare suffix, so no wrong caller passes), but it
is a real inconsistency, and the trailing-segment form is a false-negative for a
bare-stored owner. Replace both with did_matches (collapses did:key full vs bare
on both sides, never across methods), and extend the drift guard to assert
require_owner itself uses did_matches, not just that it is called.
|
||
|
|
ff492b4521 |
feat(node): signature-gated agent self-deregister (#29) (#63)
* feat(node): signature-gated agent self-deregister (#29) Agents whose keys are rotated or compromised could never be removed from the registry, so an orphaned DID persisted and could win ?capability= routing. Give the DID's key holder a way to retire their own agent. A v5 migration adds an agent lifecycle status (default 'active') plus a deactivated_at timestamp. list_agents drops non-active agents via a pure filter_discoverable helper, so a revoked DID no longer appears in discovery or capability routing; get_agent stays unfiltered so a revoked DID still resolves and surfaces its status. DELETE /api/v1/agents/{did} lets an rfc9421-authenticated caller mark its own agent revoked. Authorization is on the verified AuthenticatedDid via a pure caller_matches_did gate (a DID can only retire itself), unit-tested including the cross-DID rejection. Revocation is terminal: register_agent's ON CONFLICT leaves status untouched, so re-registering a leaked DID cannot resurrect it. Authority is team-owned (the key holder); no external contract, chain, or operator power. Lost-key retirement and any successor pointer are out of scope. * fix(node): preserve original deactivated_at on repeat revoke A retried DELETE /api/v1/agents/{did} re-ran the revoke UPDATE and overwrote deactivated_at with a fresh timestamp, losing the true first-retired time. Use COALESCE(deactivated_at, $2) so the original timestamp is kept; the row still matches so the idempotent 204 is unchanged. |
||
|
|
2d4105e479 |
chore(deps): bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185) (#83)
quinn-proto 0.11.14 (pulled transitively via libp2p's QUIC transport) is flagged by RUSTSEC-2026-0185. 0.11.15 is a semver-compatible patch that clears it; cargo audit passes clean after the bump. Lockfile-only, no manifest or code change, so it carries no ignore entry. Repo-wide: unblocks cargo audit on main and every open branch. |
||
|
|
803d83efdb |
fix(node): reap leaked git child processes (#53) (#61)
* fix(docker): reap orphan processes via tini + init, cap pids The node runs as PID 1 in the container and never reaps reparented orphans, so git grandchildren (e.g. pack-objects left behind when a served clone disconnects) accumulate as zombies until fork() fails with EAGAIN. Run under tini and set compose init: true so reparented orphans are reaped, and add pids_limit as a blast-radius cap. Refs #53 * fix(node): reap leaked git child processes (#53) Serving git over smart-HTTP could leak zombie processes that, over weeks, exhaust the PID table until fork() fails with EAGAIN and all git operations break until restart. The serve path (run_git_service) now runs git in its own process group and SIGKILLs that group when the request future is dropped (client disconnect) or returns early, so git and its pack-objects child are torn down together instead of orphaning the grandchild to PID 1 as a zombie. A drop guard, disarmed once the request completes cleanly, owns this teardown. create_issue and build_filtered_pack spawned a std Child and dropped it unwaited if the stdin write failed (and build_filtered_pack could panic on a missing stdin handle). Both now always wait the child before surfacing any error, so the failure path cannot leak. Adds libc for the group-kill syscall and three regression tests covering direct-child kill, grandchild reaping via the group, and the disarmed no-kill path. Refs #53 * fix(review): reap-then-disarm on serve-path errors; tini/comment nits Address code-review findings on the #53 fix: - run_git_service no longer early-returns on a stdin-write error. It captures the write result, always reaps the child, then disarms the group guard before surfacing the error. Previously a write/wait error path could drop the still-armed guard after the child was already reaped, so the group SIGKILL could in theory land on a reused pgid. (correctness, reliability, adversarial all flagged this region.) - Drop tini's -g flag: the node manages its own children and graceful shutdown, so group-wide signalling would only disturb in-flight git helpers on shutdown. - Correct the SAFETY comment (kill(2) is not relevant to async-signal-safety here) and the alive() test-helper comment (EPERM does not read as alive). * fix(node): SIGTERM (not SIGKILL) the served-git group on disconnect SIGKILL gave git no chance to run its signal cleanup, so an aborted receive-pack could leave a stale .git/*.lock that blocks the next push. SIGTERM lets git remove its lock files before exiting. Signalling the whole process group still reaches pack-objects directly, so the grandchild is terminated rather than orphaned, and a single immediate signal avoids any delayed-kill pid-reuse window. Refs #53 * docs(node): correct stale SIGKILL comments to SIGTERM in served-git teardown |
||
|
|
7533b74f79 |
ci: harden PR pipeline (audit, MSRV, beta matrix, release + docker smoke) (#57)
* ci: harden PR pipeline (audit, MSRV, release build, beta matrix, docker smoke) Restructure the single stable-only fmt+clippy+test job into separate jobs: fmt+clippy (stable), a test matrix on stable+beta (beta non-blocking), build --release, cargo audit, an MSRV check pinned to Rust 1.91, and a Docker build smoke test mirroring the release workflow. Add concurrency cancellation for superseded PR runs and per-job timeouts. Declare rust-version = "1.91" in [workspace.package] and inherit it in every member crate so the stated MSRV is a real, CI-enforced contract. * ci: disable credential persistence on checkout steps Set persist-credentials: false on all six actions/checkout steps. These jobs run untrusted PR code via cargo and docker build, and checkout's default leaves the GITHUB_TOKEN in the local git config where that code could read it. None of the jobs push or otherwise need git credentials. * fix(deps): bump rustls-webpki to 0.103.13 Clears three advisories flagged by the new cargo audit job: a reachable panic in CRL parsing and two name-constraint bypasses (wildcard and URI). Semver-compatible patch, lockfile only, no manifest or code change. cargo also corrects a stale lockfile reference while rewriting Cargo.lock: data-encoding-macro-internal 0.1.17 (a build-time macro crate) requires syn 1.x, and its dependency entry is re-pinned from syn 2.0.117 to the 1.0.109 it actually resolves to. Unrelated to the bump, unavoidable when cargo touches the lock, and the more-correct state. * ci(audit): suppress no-fix advisories with justification and a re-check The cargo audit job stays hard-fail. The remaining three advisories have no available upstream fix, so they are suppressed in .cargo/audit.toml (the location cargo-audit actually reads; a repo-root audit.toml is not read), each with an inline rationale and removal condition: - RUSTSEC-2026-0118/0119 (hickory-proto 0.25.2, via libp2p-dns 0.44): reachable, since the node wraps QUIC in the system DNS resolver. No fix until libp2p-dns ships hickory 0.26. Accepted, reachable risk. - RUSTSEC-2023-0071 (rsa Marvin): in the lockfile via sqlx's unconditional sqlx-mysql, but not linked (sqlx is postgres-only), so no RSA oracle exists; no upstream fix regardless. Two controls keep the suppression honest. The PR audit job echoes the active ignore list to the job summary, so a green check is not read as advisory-clean. A weekly scheduled job re-runs cargo audit without the ignores for visibility and hard-fails if hickory-proto moves off 0.25.x while the ignores remain, so the suppression cannot silently outlive the upstream fix. * ci(audit): reference tracking issue #76 in the hickory ignores * ci(audit): treat hickory-proto removal as drift too The scheduled drift guard only failed when hickory-proto was present and off 0.25.x, so removing it from Cargo.lock entirely left the RUSTSEC-2026-0118 /0119 ignores in place with the guard still passing. Treat an absent version as drift as well, so dead ignore entries are caught whether the dependency is bumped past 0.25.x or removed. |
||
|
|
abdc775770 |
feat(node): blind recipient identities at rest and gate B1 by repo readability (#40)
* feat(node): blind recipient identities at rest and gate B1 by repo readability
The origin no longer stores recipient DIDs. Migration v5 replaces the
encrypted_blobs.recipients column with an opaque, node-keyed recipients_tag
used only to detect a recipient-set change for re-seal. B1 discovery and fetch
are now gated by the same repo-readability check the git read path uses, not by
per-recipient matching; decryption is gated by the envelope crypto, so a
non-recipient who can read the repo sees a blob's {oid, cid} but cannot open it.
encrypt_and_pin keys the tag from the node seed and returns {oid, cid}; the
Arweave manifest tuple drops the now-unused recipient vec.
A DB compromise no longer reveals the reader set; recovering it would require
brute-forcing candidate DID sets against the keyed tag with the node key.
* chore: remove redundant .gitignore entry (covered by local exclude)
* fix(node): gate /replicate by repo readability; harden reseal on DB error
Address review on the at-rest blinding change:
- The encrypted-blobs/replicate listing returned {oid, cid} with no visibility
check, so a non-readable repo's blob index was reachable by an unauthenticated
caller who guessed {owner}/{repo}. Gate it by the same repo-readability check
discovery and fetch use. For the intended case (a public repo with withheld
subtrees) the public root keeps this open to peers; only fully non-readable
repos are withheld, which is the desired behavior.
- encrypt_and_pin treated a recipients_tag DB read error as a cache miss and
resealed, causing avoidable IPFS writes during a partial outage; skip and retry
on the next push instead.
- Correct the get_encrypted_blob doc comment to describe repo-readability access.
|
||
|
|
e37ea7fec6 |
fix(node): enforce path-scoped visibility on the REST read API (#52)
* fix(node): enforce path-scoped visibility on the REST read API The repo-scoped REST read endpoints (blob, tree, commits, refs, changelog, pulls, pr diff, repo metadata) read straight from disk after a bare repo lookup, with no visibility check, so private repositories and private subtrees of public repositories were served to unauthenticated callers, bypassing the withholding the git and replication paths enforce. Add a shared authorize_repo_read gate (resolve repo, load rules, visibility_check, 404 on deny) and call it from every repo-scoped read handler. get_blob gates at the requested file path; get_pr_diff withholds the whole diff when it touches a path the caller cannot read. Attach optional_signature to the read routes so authorized readers authenticate. Listings stay gated at the repo root, consistent with mode B exposing tree and commit SHAs over git. Closes #51 * fix(node): gate PR reviews/comments and fork on repo read visibility The REST visibility gate added in this PR missed three read surfaces: list_reviews and list_comments served a private repo's PR reviews and comments to unauthenticated callers, and fork_repo cloned a full mirror of the source without checking the caller could read it. Route all three through authorize_repo_read at the repo root, matching the git-protocol gate. Also make branch_diff_names fail closed: it ignored git's exit status and returned an empty path list on error, which would let get_pr_diff serve a diff it should have withheld. Fix changelog.rs import order to satisfy cargo fmt. * fix(node): NUL-delimit branch_diff_names to keep path-gate enforcement exact git diff --name-only quotes and escapes paths containing newlines, so they no longer match the visibility globs in get_pr_diff. On a public repo where / is allowed but a subtree is denied, a touched restricted path could be missed by the deny check and the full PR diff disclosed. Use -z and split on NUL to preserve exact path bytes. |
||
|
|
197c4e1cf1 |
Add contributor templates, issue forms, and PR triage automation (#58)
* docs: add pull request template * docs: add structured issue forms and template chooser config * chore: add CODEOWNERS for CI, release, and security paths * ci: add quality-signal PR triage workflow * ci: add label-gated auto-stale workflow for ignored PRs * docs: document PR quality bar and triage/stale policy in CONTRIBUTING * ci: recognize inline #[cfg(test)] tests and fix triage comment grammar |
||
|
|
a5d2264cc0 |
ci(release): smoke-test packaged binaries' --version before attaching (#59)
The release-binaries job packaged gl, git-remote-gitlawb, and gitlawb-node into the release tarball but never ran them, so a broken artifact could ship. This is the follow-up promised in #30 and the gap noted in OSS-READINESS-AUDIT ("release binaries are not smoke-tested after packaging"), now unblocked by git-remote-gitlawb gaining --version. Run each binary's --version after build and assert the released version string is present. Skipped for aarch64-unknown-linux-musl, which is cross-compiled and can't execute on the x86_64 runner. Co-authored-by: FrozenRaspberry <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> |
||
|
|
5ff7af84fa |
feat: encrypted replication for private subtrees (B1/B2/B3) for #18 (#36)
* feat(core): ed25519 to x25519 key conversion for envelope crypto * feat(core): seal/open per-blob encryption envelopes * feat(node): resolve per-blob encryption recipients (least-privilege) * feat(node): encrypted_blobs table and recipient-scoped queries * feat(node): encrypt-then-pin withheld blobs at push (IPFS) At the push chokepoint, after pinning withheld objects, resolve each withheld blob's recipient DIDs and seal it to their Ed25519 keys with seal_blob, pinning the ciphertext to IPFS and recording it in encrypted_blobs. Best-effort per blob: failures are logged and skipped, never pinned in plaintext. Pinata replication is unchanged; B1 encrypts to IPFS only. Adds ed25519-dalek as a direct dependency of gitlawb-node (it was only declared in the workspace Cargo.toml). * feat(node): authenticated discovery and fetch for encrypted blobs * feat(gl): transparent recovery of authorized encrypted blobs on clone * style: cargo fmt * fix(node): re-seal encrypted blob when recipient set changes Skip re-pinning only when an existing envelope already covers exactly the current recipients. A reader added to a rule after the first pin now gets a re-seal on the next push instead of being permanently locked out. Reader removal stays non-retroactive (the old envelope is already public). * feat(node): unscoped encrypted-blob listing for replication * feat(node): encrypted-blobs/replicate endpoint for peer mirrors * feat(node): route encrypted-blobs/replicate under git_read_routes * feat(node): peer mirrors replicate encrypted withheld blobs (Option B2) * feat(node): anchor_encrypted_manifest for Option B3 Arweave manifests * feat(node): encrypt_and_pin returns the per-push sealed delta * feat(node): anchor encrypted-blob manifest on push (Option B3) * feat(gl): Arweave/IPFS gateway recovery for encrypted blobs (Option B3) * feat(gl): transparent Arweave fallback recovery on clone (Option B3) * style: cargo fmt * fix(gl): bound Arweave recovery gateway requests with a 30s timeout * test(gl): read-path recovery tests for Arweave fallback; quiet promisor present-check Add two hermetic integration tests for recover_from_arweave that drive the full read path over mocked Arweave GraphQL + IPFS gateways: discover the manifest, fetch it, fetch the envelope, decrypt, and install the withheld blob. One covers an authorized recipient (blob installed), the other a non-recipient (nothing recovered). Both simulate origin death by removing the promisor remote and enable uploadpack.allowFilter so the blob is truly withheld over file://. Also harden the local presence check in recover_from_arweave with GIT_NO_LAZY_FETCH=1 and .output() so the expected 'missing object' case does not trigger a wasted promisor fetch or leak git stderr to the user. * fix: surface corrupt recipients JSON and silent recovery I/O failures db: parse_recipients now propagates a descriptive error instead of defaulting corrupt recipients JSON to an empty list, which would have denied authorized readers and handed peers incomplete metadata. gl: clone recovery now warns when the sparse-checkout file cannot be read or written, or when the post-recovery checkout fails, instead of silently discarding those errors and claiming files were recovered. * feat(core): blind recipient identities in withheld-blob envelopes Envelope v2 drops the cleartext recipient public key (kid) from each wrapped-key header entry, so a party holding the public IPFS/Arweave copy can no longer enumerate who is authorized to decrypt a withheld blob. The version is bumped to 2 and v1 envelopes are rejected. open_blob now selects the reader's entry by trial decryption (the AEAD tag authenticates exactly one entry) instead of matching on the public key. Recipient count is still visible; identity is not. Scope is envelope-only, the node DB recipients column and peer replication metadata are unchanged. Co-authored-by: CommandCodeBot <[email protected]> * fix(core): reject malformed envelope nonces instead of panicking open_blob fed attacker-controlled envelopes to GenericArray::from_slice and XNonce::from_slice, which panic on a wrong-length input. Validate both the per-recipient box nonce and the body nonce to 24 bytes before use: skip a recipient entry whose nonce is malformed, and return an error for a malformed body nonce, so the public recovery path surfaces an error rather than panicking. * feat(node): blind recipient identities on the B2 replication surface The encrypted-blobs/replicate endpoint shipped every blob's full recipient DID list to mirrors, which persisted it, so any mirroring peer learned the reader set. The v2 envelope blinding already removed recipient public keys from the pinned envelopes, so the comment justifying this (DIDs are already public) was no longer true. /replicate now returns {oid, cid} only. Mirrors detect a re-seal by the CID changing (the OID is stable across re-seals) instead of comparing recipient sets, and store no recipient identities. Origin-side authz and the at-rest recipients column are unchanged; this blinds only the peer-facing surface. * feat(node): stop anchoring recipient identities to Arweave The B3 encrypted-blob manifest anchored {oid, cid, recipients} to Arweave, a permanent public record of every blob's reader set. The v2 envelope blinding already removed recipient keys from the pinned envelopes, so the comment justifying this (recipient DIDs are already public) was no longer true. The manifest now anchors {oid, cid} only. The gl reader already ignores the recipients field (it recovers by trial decryption), so no reader changes. --------- Co-authored-by: CommandCodeBot <[email protected]> |
||
|
|
8680d0f9d6 |
feat(node): replication enforcement (Phase 2) for #18 (#34)
* docs(node): Phase 3 subtree-withholding plan with Task 0 spike findings * feat(node): resolve withheld blob OIDs for path-scoped visibility * feat(node): filtered upload-pack serve that omits withheld blobs * feat(node): serve filtered pack when caller has withheld subtree blobs * test(node): end-to-end assert served pack omits withheld blob * docs(node): note why info/refs is not gated on subtree visibility * fix(node): frame filtered serve as protocol v0 and prove it with a real clone upload_pack_excluding emitted a v2 packfile section, but info_refs advertises v0, so real clients negotiated v0 and rejected the response with 'expected ACK/NAK, got packfile'. Frame the v0 stateless-rpc shape instead (NAK, then the pack via side-band-64k when offered). Add an end-to-end test that stands up info_refs + upload_pack_excluding and runs a real git partial clone, asserting the withheld blob's bytes never reach the client while its tree entry and SHA stay visible. A stock full clone cannot consume the pack (it is not closed under reachability, so fetch fails the connectivity check); a partial clone is required. * docs(node): correct Phase 3 caveat (full clone refused at fetch, not checkout) * test(node): prove incremental fetch still withholds; document negotiation choice Add a real-git test that partial-clones, pushes a new commit server-side, then fetches: the new object arrives and the withheld blob stays absent. This pins down that ignoring have/want negotiation (always sending a self-contained pack of all refs minus withheld, with NAK) is correct for both clone and fetch; the only cost is a fetch re-sends the full object set. Refactor the real-git tests onto a shared server harness and document the negotiation decision in code and in the plan's follow-ups. * refactor(node): address CodeRabbit review on PR #28 Move the two blocking git shell-outs in the filtered upload-pack path off the async worker thread, matching the tokio::process / spawn_blocking usage already in this file: build_filtered_pack (rev-list + pack-objects) and withheld_blob_oids (per-ref ls-tree) now run inside spawn_blocking so a large repo cannot stall the tokio runtime. Behavior is unchanged. Also fix the Task 0 findings block in the Phase 3 plan: it still recorded v2 packfile framing, which is the exact path that failed against a real client and was corrected to v0. The block now documents the shipped v0 contract. Drop a stray trailing code fence flagged by markdownlint (MD040). The speculative ls-tree timeout and the public/no-rules fast-path from the review are intentionally left out: the timeout guards against adversarial repos we do not yet host, and the fast-path is a micro-optimization not worth the extra branch right now. * chore(node): drop planning doc from PR per maintainer request kevincodex1 asked to keep the superpowers planning docs out of the repo. The Phase 3 plan was scaffolding for this change, not something the project needs to carry. Removing it leaves only the code and tests in the PR. * chore: gitignore local planning docs (docs/superpowers/) * feat(node): replicable_objects filter for replication enforcement * test(node): pin anonymous-caller contract of withheld_blob_oids * feat(node): IPFS pinning skips withheld blob OIDs * feat(node): Pinata pinning skips withheld blob OIDs * feat(node): enforce visibility on push replication (IPFS/Pinata/gossip/Arweave) * test(node): announce gate matches anonymous repo readability * fix(node): run withheld_blob_oids off the async worker on push The receive-pack replication chokepoint called withheld_blob_oids directly on the tokio worker, where its blocking git ls-tree walk can stall the runtime for repos with many refs. Wrap it in spawn_blocking to match the upload-pack serve path. --------- Co-authored-by: Kevin Codex <[email protected]> |
||
|
|
c03c9af8fa |
feat(sync): auto-register as replica with origin after successful mirror (#56)
The sync worker mirrored repos from peers but never told the origin, so
replica_count (repo_replicas table, GET /api/v1/repos/:owner/:repo/replicas)
stayed at 0 network-wide — the dashboard showed 0 replication even for
repos that were actually mirrored.
After each successful clone/fetch the worker now sends a signed
PUT /api/v1/repos/{owner}/{repo}/replicas to the origin with our
public_url, using the same HTTP-Signature scheme as push notifies.
Best-effort by design: skipped when public_url is unset, failures are
logged without failing the sync, and the PUT is idempotent on the origin
(201 first, 200 after) so re-registering every sync is self-healing.
Nodes need this build before their mirrors are counted: a new node's
repos show replication once its peers upgrade.
Co-authored-by: OpenClaude <[email protected]>
|
||
|
|
7a8b6c4974 |
Aws ecr image (#55)
* feat(infra): optional private ECR repo for the node image create_ecr_repo = true provisions an ECR repository in-account and switches the instance to pull from it: the instance role gets AmazonEC2ContainerRegistryReadOnly and user-data configures amazon-ecr-credential-helper, so first-boot pulls and SSM upgrades authenticate via the role (no expiring login tokens). README documents the local build-and-push flow. Motivation: ghcr.io/gitlawb/node is not anonymously pullable (403), which left first boot unable to start the stack. Co-Authored-By: OpenClaude <[email protected]> * feat(infra): HTTPS via Caddy + production tier (RDS, alarms, S3 state) - domain_name: Caddy sidecar terminates TLS on 80/443 with auto-renewed Let's Encrypt certs (persisted on the data volume); public_url defaults to https://<domain>; SG opens 80/443 — matches the Fly nodes - use_rds: managed Postgres (db.t4g.micro, encrypted, 7-day PITR, deletion protection) replaces the in-container postgres; DB security group only admits the node instance - alert_email: SNS email alerts wired to CloudWatch alarms — external HTTPS /health via Route53 health check, EC2 status checks, CPU, RDS free storage - user-data grows the filesystem when the EBS volume is enlarged - terraform state moved to S3 (versioned, KMS-encrypted, public-blocked bucket) with lockfile locking (requires terraform >= 1.10) Co-Authored-By: OpenClaude <[email protected]> --------- Co-authored-by: OpenClaude <[email protected]> |
||
|
|
7a99d0f27c |
fix(node): make Tigris repo hydration resilient to corrupt archives & failed writes (#54)
Repo pushes 500'd whenever a repo's Tigris archive was unreadable (corrupt or truncated) or the node was under disk pressure, because hydration was treated as fatal and a failed extraction could clobber the good local copy. Three hardening changes: - tigris: extract archives atomically (unpack into a temp dir, swap into place only on success) so a corrupt/truncated archive can never clobber a good existing local copy. - repo_store: acquire_fresh / acquire_write fall back to the valid local copy when a Tigris download fails, instead of hard-failing the operation; the post-write upload then re-syncs (self-heals) Tigris. - repo_store: RepoWriteGuard::release(success) uploads to Tigris only when the write succeeded, so a half-applied / inconsistent repo is never propagated to Tigris or to other nodes. Co-authored-by: OpenClaude <[email protected]> |
||
|
|
c7df6cad20 |
Fix/backport prod fixes (#39)
* fix: backport production fixes from monorepo Three fixes that landed on the production node but never made it here: - trigger_sync: 30s timeout with the body read inside it (was 5s covering headers only — large unpaginated repo lists from canonical nodes and transpacific peers aborted mid-body), plus distinct logging for non-2xx vs timeout - announce: reject self-announcements by public URL or own DID; complements the existing boot-time prune_self_peers so self-loop rows never enter the peers table in the first place - list_all_repos_paged: report MAX(updated_at) across each logical repo group instead of the canonical row's own value — gossip pushes touch only the mirror row, so canonical timestamps go stale Monorepo refs: 8c2fe1b, c1dc33e, ef21c75. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(p2p): dual-stack QUIC listen + DNS multiaddr dialing for Fly 6PN Backports the monorepo TCP-era p2p fixes (9f875b6, 267d371) adapted to the QUIC transport: - listen on /ip6/:: as well as /ip4/0.0.0.0 — Fly's 6PN inter-app network is IPv6-only (<app>.internal resolves to AAAA records), so without an IPv6 socket peers on the private network can't reach us - wrap the QUIC transport in libp2p-dns so bootstrap multiaddrs can use /dns6/<app>.internal — dialing peers through the public anycast edge breaks the handshake when the proxy closes the connection mid-stream Co-Authored-By: Claude Fable 5 <[email protected]> * fix(sync): HTTP notify records received_ref_updates like the gossip path A push propagated via the /api/v1/sync/notify HTTP fallback replicated the repo but left no trace in received_ref_updates — only the libp2p gossip path recorded it, so /api/v1/events/ref-updates went blind whenever the mesh wasn't delivering. Insert the same record from the notify handler, with optional provenance fields (pusher_did, old_sha, timestamp, cert_id) that new senders already include. Monorepo ref: 087eeac. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]> |
||
|
|
b61a1bd46f |
fix(api): blob endpoint returns 400/404 instead of 500 on bad paths (#37)
Unnormalized "../.." paths from crawler-exploded relative links made `git show` fail and surfaced as 500s. Reject traversal segments with 400 up front, and map paths absent from the tree to 404 so only real git failures count as server errors. Co-authored-by: Claude Fable 5 <[email protected]> |
||
|
|
e365a57b51 |
feat(node): peer partial-mirrors for repos with private subtrees (#35)
* feat(node): withheld-paths endpoint reporting a caller's denied globs * feat(gl): gl clone with promisor + sparse-exclude for private subtrees * test(gl): gl clone repo-argument parsing * fix(visibility): gate withheld-paths and handle nested/exact globs Three fixes from the PR #33 review: - withheld_paths now applies the whole-repo "/" read gate (returns repo-not-found when the caller cannot read the root), matching the git read endpoints. Without it the endpoint disclosed a private repo's existence and path layout to unauthorized callers. The withheld_globs doc already assumed this gate existed; now it does. - A nested allow under a denied parent (e.g. "/secret/public/**" allowed, "/secret/**" denied) was over-withheld: the client sparse-excluded the whole parent and hid paths the caller may read. The endpoint now also returns a "reinclude" list (allowed globs strictly under a denied one) and gl clone re-includes them in the sparse spec after the excludes. - Wildcard-free globs like "/docs/private" match both the exact path and a subtree (per glob_matches), but the client only emitted the subtree exclude. sparse_patterns now emits both "/docs/private" and "/docs/private/". Verified the exclude-then-reinclude sparse ordering checks out cleanly with real git, plus unit tests for reincluded_globs, the nested re-include, the exact-path exclude, and sparse_patterns. * fix(gl): reject multi-slash repo input and stop failing open on withheld-path errors split_once('/') accepted owner/name/extra, smuggling a path segment into the repo name that then flowed into the API path and clone URL; reject it. fetch_withheld swallowed every network/auth/5xx/JSON error into an empty result, dropping to a stock clone that the node refuses once blobs are withheld. Now only 404/501 (endpoint unsupported) fall back to empty; the rest propagate so the real cause surfaces. * test(gl): guard three-level nested visibility in sparse clone Add a regression test for deny /secret, allow /secret/public, deny /secret/public/admin and clarify the sparse-checkout comment. git does not re-traverse an explicitly excluded directory, so emitting all excludes before re-includes keeps the deepest deny in force; the broader parent re-include does not resurrect it. * fix(gl): robust default-branch detection and strict withheld-paths schema Read the default branch from the local origin/HEAD symref clone already set, instead of parsing the localized, network-dependent output of git remote show origin. Deserialize the withheld-paths body into a typed struct so a missing or mistyped withheld/reinclude field is a hard error rather than silently becoming an empty list, which would mask a server regression behind a later clone failure. * feat(node): classify mirror mode from origin withheld-paths * feat(node): promisor-aware mirror clone for withheld repos * feat(node): promisor-aware mirror fetch via origin remote * feat(node): classify and mirror peers per withheld-paths * style: cargo fmt * fix(node): bound sync withheld lookup and rehydrate on promisor->plain Give the sync worker's HTTP client a 30s timeout so a stalled peer withheld-paths lookup cannot hang the worker loop. When a repo that was mirrored as a promisor (mode B) becomes fully public, fetch_repo now clears remote.origin.promisor and partialclonefilter and refetches, so the once-withheld blobs are backfilled instead of the mirror staying permanently partial. |
||
|
|
4a59056a90 |
gl clone: clean partial clone of repos with private subtrees (#33)
* feat(node): withheld-paths endpoint reporting a caller's denied globs * feat(gl): gl clone with promisor + sparse-exclude for private subtrees * test(gl): gl clone repo-argument parsing * fix(visibility): gate withheld-paths and handle nested/exact globs Three fixes from the PR #33 review: - withheld_paths now applies the whole-repo "/" read gate (returns repo-not-found when the caller cannot read the root), matching the git read endpoints. Without it the endpoint disclosed a private repo's existence and path layout to unauthorized callers. The withheld_globs doc already assumed this gate existed; now it does. - A nested allow under a denied parent (e.g. "/secret/public/**" allowed, "/secret/**" denied) was over-withheld: the client sparse-excluded the whole parent and hid paths the caller may read. The endpoint now also returns a "reinclude" list (allowed globs strictly under a denied one) and gl clone re-includes them in the sparse spec after the excludes. - Wildcard-free globs like "/docs/private" match both the exact path and a subtree (per glob_matches), but the client only emitted the subtree exclude. sparse_patterns now emits both "/docs/private" and "/docs/private/". Verified the exclude-then-reinclude sparse ordering checks out cleanly with real git, plus unit tests for reincluded_globs, the nested re-include, the exact-path exclude, and sparse_patterns. * fix(gl): reject multi-slash repo input and stop failing open on withheld-path errors split_once('/') accepted owner/name/extra, smuggling a path segment into the repo name that then flowed into the API path and clone URL; reject it. fetch_withheld swallowed every network/auth/5xx/JSON error into an empty result, dropping to a stock clone that the node refuses once blobs are withheld. Now only 404/501 (endpoint unsupported) fall back to empty; the rest propagate so the real cause surfaces. * test(gl): guard three-level nested visibility in sparse clone Add a regression test for deny /secret, allow /secret/public, deny /secret/public/admin and clarify the sparse-checkout comment. git does not re-traverse an explicitly excluded directory, so emitting all excludes before re-includes keeps the deepest deny in force; the broader parent re-include does not resurrect it. * fix(gl): robust default-branch detection and strict withheld-paths schema Read the default branch from the local origin/HEAD symref clone already set, instead of parsing the localized, network-dependent output of git remote show origin. Deserialize the withheld-paths body into a typed struct so a missing or mistyped withheld/reinclude field is a hard error rather than silently becoming an empty list, which would mask a server regression behind a later clone failure. |
||
|
|
3a401eb079 |
feat(git-remote-gitlawb): add --version and --help flags (#30)
git-remote-gitlawb was the only workspace binary without `--version`, so
release smoke tests could not validate the packaged helper directly. This
is called out in docs/MAINTAINER-ROADMAP.md ("Add --version support to
git-remote-gitlawb so release smoke tests can validate it directly") and
docs/OSS-READINESS-AUDIT.md.
- `--version`/`-V` prints `git-remote-gitlawb <version>`, matching the
`<bin> <version>` format the clap-based `gl` and `gitlawb-node` emit so
release tooling can treat all three binaries uniformly. The version
comes from CARGO_PKG_VERSION.
- `--help`/`-h` prints concise usage (URL form, env vars, flags).
- Flags are only recognized as the first argument, so git's
`<remote-name> <url>` remote-helper invocation path is untouched; a
remote literally named "--version" is not mistaken for the flag.
- Informational flags are handled before tracing init so output stays
clean for scripts.
Adds unit tests for argument classification (including the no-args and
misplaced-flag edge cases) and the version/help strings.
Co-authored-by: FrozenRaspberry <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
|
||
|
|
61b383019f |
feat(node): subtree content withholding (Phase 3) for #18 (#28)
* docs(node): Phase 3 subtree-withholding plan with Task 0 spike findings * feat(node): resolve withheld blob OIDs for path-scoped visibility * feat(node): filtered upload-pack serve that omits withheld blobs * feat(node): serve filtered pack when caller has withheld subtree blobs * test(node): end-to-end assert served pack omits withheld blob * docs(node): note why info/refs is not gated on subtree visibility * fix(node): frame filtered serve as protocol v0 and prove it with a real clone upload_pack_excluding emitted a v2 packfile section, but info_refs advertises v0, so real clients negotiated v0 and rejected the response with 'expected ACK/NAK, got packfile'. Frame the v0 stateless-rpc shape instead (NAK, then the pack via side-band-64k when offered). Add an end-to-end test that stands up info_refs + upload_pack_excluding and runs a real git partial clone, asserting the withheld blob's bytes never reach the client while its tree entry and SHA stay visible. A stock full clone cannot consume the pack (it is not closed under reachability, so fetch fails the connectivity check); a partial clone is required. * docs(node): correct Phase 3 caveat (full clone refused at fetch, not checkout) * test(node): prove incremental fetch still withholds; document negotiation choice Add a real-git test that partial-clones, pushes a new commit server-side, then fetches: the new object arrives and the withheld blob stays absent. This pins down that ignoring have/want negotiation (always sending a self-contained pack of all refs minus withheld, with NAK) is correct for both clone and fetch; the only cost is a fetch re-sends the full object set. Refactor the real-git tests onto a shared server harness and document the negotiation decision in code and in the plan's follow-ups. * refactor(node): address CodeRabbit review on PR #28 Move the two blocking git shell-outs in the filtered upload-pack path off the async worker thread, matching the tokio::process / spawn_blocking usage already in this file: build_filtered_pack (rev-list + pack-objects) and withheld_blob_oids (per-ref ls-tree) now run inside spawn_blocking so a large repo cannot stall the tokio runtime. Behavior is unchanged. Also fix the Task 0 findings block in the Phase 3 plan: it still recorded v2 packfile framing, which is the exact path that failed against a real client and was corrected to v0. The block now documents the shipped v0 contract. Drop a stray trailing code fence flagged by markdownlint (MD040). The speculative ls-tree timeout and the public/no-rules fast-path from the review are intentionally left out: the timeout guards against adversarial repos we do not yet host, and the fast-path is a micro-optimization not worth the extra branch right now. * chore(node): drop planning doc from PR per maintainer request kevincodex1 asked to keep the superpowers planning docs out of the repo. The Phase 3 plan was scaffolding for this change, not something the project needs to carry. Removing it leaves only the code and tests in the PR. * chore: gitignore local planning docs (docs/superpowers/) |
||
|
|
a2217bff27 |
fix(infra): drop fly idle_timeout 600 -> 120 (#38)
600s idle windows let hung clients pin Fly proxy connection slots up to hard_limit, which amplified the 2026-06-12 production outage. 120s was deployed there as the fix; carry it into the reference config. Co-authored-by: Claude Fable 5 <[email protected]> |
||
|
|
6abaf1d7ed |
feat: path-scoped repository visibility (Phase 1) for #18 (#25)
* feat: path-scoped repository visibility (Phase 1) Implements per-path read visibility for repositories, addressing #18. A repo's private status becomes a property of a path subtree rather than a whole-repo boolean, enforced on the git read path via the node's existing DID identity and RFC 9421 HTTP signatures. Phase 1 scope (whole-repo enforcement, data model for the rest): - visibility_rules data model (path_glob, mode, reader_dids) + DB access - visibility_check pure decision function (owner-always-allow, most- specific rule wins, reader-DID allow-list, is_public fallback) - enforcement on git_info_refs and git_upload_pack: unauthorized reads return 404 byte-identical to a missing repo (no existence leak) - owner-only management API: PUT/DELETE/GET /api/v1/repos/{o}/{r}/visibility - gl visibility set/remove/list CLI mode A (hide) is whole-repo ("/") only by construction: hiding a subtree's existence would rewrite ancestor tree hashes and diverge history. Subtrees use mode B (content withheld, SHAs intact); B-subtree clone filtering is deferred to a later phase and the CLI/API say so. 260 tests (69 node + 191 gl); fmt + clippy clean. * fix: address review on path-scoped visibility - migrations: move visibility_rules DDL out of the v1 migration into a new v3 (CodeRabbit, critical). Appending to v1 meant nodes that had already recorded v1 would skip the statements forever, so the table would be missing after upgrade. v3 matches the convention #23 set with v2 (agent_profiles) and the documented 'never append to v1' rule. - api: validate path_glob on set (must start with '/', no trailing '/', only a trailing '/**' wildcard, '/' for whole-repo not '/**'). Empty or slash-less globs would otherwise silently misconfigure access. Added unit tests for the accepted/rejected forms. 281 -> 283 tests pass (88 node + 195 gl); fmt + clippy clean. |
||
|
|
9e6e832ec5 |
Infra reorg (#27)
* refactor: move fly.toml into infra/fly/ Deployment configs now live under infra/, organized per target. Dockerfiles and docker-compose.yml intentionally stay at the repo root (shared by CI, build scripts, and the macOS app). Deploy with: fly deploy -c infra/fly/fly.toml Co-Authored-By: OpenClaude <[email protected]> * feat(infra): Terraform for single-EC2 AWS deployment infra/aws/ provisions a production-leaning single-node deployment: - t4g.small (AL2023 arm64) running the published ghcr.io/gitlawb/node image + postgres:16 via docker compose - separate encrypted EBS data volume (prevent_destroy) with daily DLM snapshots; survives instance replacement - postgres password via random_password -> SSM SecureString, fetched at boot by instance profile (never in user-data); optional secrets (operator key, Pinata JWT, S3 secret) follow the same path - SSM Session Manager access (no SSH by default), IMDSv2 required, metrics port closed unless explicitly opened - SSM command document for image upgrades (user-data runs once) Verified: terraform fmt/init/validate clean; rendered compose passes docker compose config; rendered user-data passes bash -n. Co-Authored-By: OpenClaude <[email protected]> * fix(infra): address CodeRabbit review feedback - api_url output now honors the public_url override (local.public_url) - disable xtrace around SSM secret fetch + .env write so secret values never reach the bootstrap log - add retry/timeout flags to the docker compose download - validate snapshot_retain_count (1-1000) at plan time - add language tags to fenced tree blocks (MD040) Co-Authored-By: OpenClaude <[email protected]> * fix(infra): address PR #27 review feedback - security group now derives its VPC from the selected subnet, so subnet_id overrides into non-default VPCs work - s3_access_key_id moves to SSM SecureString (same path as the secret key) instead of being embedded in user-data and state - ignore user_data drift on the instance: it only runs at first boot, so re-rendering caused pointless stop/starts; README documents the -replace workflow for config changes - DLM snapshot targeting is stack-specific (Snapshot + Name tags), not any Snapshot=true volume in the account - comment in fly.toml that it targets the shared test instance - user-data compose up gains --remove-orphans (consistent with the upgrade SSM document) - node service gets an HTTP /health healthcheck in compose Co-Authored-By: OpenClaude <[email protected]> * feat(infra): optional customer-managed KMS key for SSM secrets Add ssm_kms_key_id (default null = AWS-managed aws/ssm key). When set, all five SecureString parameters are encrypted with the CMK and the instance role gains kms:Decrypt scoped to that key — without it the boot-time secret fetch would fail. Co-Authored-By: OpenClaude <[email protected]> * fix(infra): scope CMK kms:Decrypt to this stack's SSM parameters The grant on the customer-managed key was unconditioned, allowing the instance role to decrypt any ciphertext under the same CMK. Restrict it with kms:ViaService = ssm.<region>.amazonaws.com and kms:EncryptionContext:PARAMETER_ARN limited to the stack's parameter ARNs. Co-Authored-By: OpenClaude <[email protected]> --------- Co-authored-by: OpenClaude <[email protected]> |