Compare commits

...
Author SHA1 Message Date
beardthelion 6cc97e48f2 Merge remote-tracking branch 'origin/main' into tmp-412-merge
# Conflicts:
#	crates/gitlawb-node/src/api/repos.rs
#	crates/gitlawb-node/src/git/store.rs
#	crates/gitlawb-node/src/test_support.rs
2026-11-04 21:20:46 -06:00
beardthelionandKevin Codex 288ad92fe8 fix(node): bound the request body the signature middleware buffers before it verifies (#262)
* fix(node): bound the request body the signature middleware buffers before it verifies

`require_signature` buffered the whole request body with an unlimited
`body.collect()` before parsing a single header, and nothing upstream capped it.
On the git write routes the 2 GB `RequestBodyLimitLayer` is applied to the inner
router while `add_auth_layers` wraps it, and a later `.layer()` is the outer
service, so the middleware ran first and saw the raw body. The other signed
groups carry no limit layer at all and lean on axum's 2 MB `DefaultBodyLimit`,
which the extractors enforce through a request extension this middleware never
consults, so that default was bypassed too. `optional_signature` forwards to the
same code whenever a caller merely presents signature headers, so the reach
extended to `/graphql`, `/ipfs/{cid}` and the other optional-signature groups.

Measured against the real router: an unsigned push with `GITLAWB_MAX_PACK_BYTES`
set to 64 KiB drained the full 4 MiB body and only then returned 401. No DID, no
signature, and no repo were required, and in front of it sits only a per-IP
arrivals-per-hour counter, no timeout layer and no connection cap.

The collect now runs through `http_body_util::Limited`, so reading stops at the
limit instead of after the fact, and an over-limit request gets 413 rather than
the 400 reserved for a genuinely unreadable body. The limit is per route group:
`add_auth_layers` takes it and passes it to the middleware as state, the git
groups keep `max_pack_bytes` so large pushes are unaffected, and the rest get a
constant equal to axum's default because that is already what their extractors
enforce. Verification, content-digest, clock-skew and the request reconstruction
are untouched.

The regression test asserts bytes actually polled out of a streaming body, not
the status code, because a status assertion passes whether or not the body was
drained. Neutralizing the limit turns it red; handing every group the git limit
turns the non-git test red; returning 400 instead of 413 turns the status test
red.

This bounds per-request buffering only. Total resident memory is still
O(connections x limit); a read timeout and a connection cap are what close that
and are deliberately not in this change.

* test(node): prove a signed push under the limit lands on disk, and an over-limit one writes nothing

The body-limit guard was covered only by tests that reject. Nothing proved it
still admits what it should, so the fix could have passed every test by refusing
everything, and nothing would have caught a middleware that truncated or
corrupted the body it hands downstream.

These drive a real push: a source repo with a 300 KiB incompressible blob, an
empty bare repo at the path the store resolves, and the byte-for-byte
receive-pack request body captured off the wire from an actual `git push`. The
body is captured rather than generated, because `git send-pack --stateless-rpc`
emits remote-curl's extra RPC framing (a wrapping length plus a stray flush
before PACK) and `git receive-pack --stateless-rpc` rejects it with a pack
signature mismatch.

The positive case sets the cap just above the body and asserts the ref moved and
the tree resolves, not merely that the status was 200. That distinction is
load-bearing: under a truncation mutation the status stays 200, because git
reports the failure inside report-status, and only the on-disk assertion goes
red. Running the whole suite under that mutation leaves exactly one test failing,
so this is the only thing standing between a corrupted body and a green build.

The negative case sets the cap just below the body and asserts 413 with neither
the ref nor the commit present afterwards, so a rejected push cannot half-write.
Its existence check peels to ^{commit}, since rev-parse echoes any well-formed
40-hex string back and would have passed vacuously.

* test(node): give the unresolvable-DID middleware test the signature body limit state

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-05 03:10:35 +00:00
beardthelion 86c544a577 fix(core): reject small-order Ed25519 keys at DID resolution and before X25519 (#314)
* fix(core): reject small-order Ed25519 keys at DID resolution and before X25519

A did:key can encode a small-order (weak) Ed25519 point. VerifyingKey::from_bytes
only decompresses, so such a key resolved cleanly, and x25519_public converted it
to the all-zero Montgomery u. X25519 against u = 0 is the all-zero shared secret
for every scalar, so the per-recipient wrap in seal_blob could be rebuilt with no
secret at all. Since one content key is wrapped per recipient, a single weak
recipient exposed the blob for every recipient.

Reject in Did::to_verifying_key, which is the choke point every consumer that
resolves a DID shares, so a weak key becomes unresolvable and the existing
fail-closed recipient handling catches it without a new code path. Guard
x25519_public as well so the primitive is safe for a caller that obtained the key
some other way.

The attacker test performs the real attack rather than asserting an absence: with
either guard removed it recovers the plaintext and fails printing it.

* fix(attest): mirror the small-order key rejection in its own did:key parser

gitlawb-attest parses did:key itself in verifying_key_from_did_key and never
routes through gitlawb-core's Did. It cannot: gitlawb-core is a dev-dependency
here, so the choke-point rejection is structurally unreachable from this crate
and has to be mirrored rather than inherited.

Signature verification is already strict, so this is defense in depth against a
future consumer of this parser that does not verify strictly, not the closing of
an exploitable path.

One consequence worth naming: verify_rejects_weak_key_signature now observes
Error::Did rather than Error::Signature, because the weak signer is refused at
resolution before verify_strict is reached. That test is retargeted accordingly.
It no longer exercises verify_strict, which remains the second layer for
malleability that does not involve a weak public key (a small-order R under an
honest key); this fixture cannot construct that case.

* test(node): pin that a weak recipient DID falls into the existing fail-closed arm

Because a small-order did:key no longer resolves, it lands in the unresolved set
and plan_seal returns the #47 SkipUnresolvable arm that already refuses to seal
to a partial recipient set. Nothing is sealed for the blob and the existing
bounded operator warning fires. No new branch, no new SealPlan variant, no new
log site.

The must-not-over-reject control asserts an all-legitimate set still seals.

* fix(gl): stop telling a did:key that did:key is unsupported

did_resolve reported every resolution failure as "only did:key is supported
without a resolver". That is right for a method with no local resolver and
misleading for a did:key that parsed fine and failed for its own reason, which
now includes a small-order key.

Split the message on whether the DID is a did:key and surface the underlying
error when it is. Extracted as a pure helper so both branches are unit-testable
without an MCP client harness.

* fix(review): restore verify_strict coverage the parser guard displaced

Refusing a small-order key at DID resolution means a weak signer can no longer
reach attestation.rs's verify_strict through verify_signature, which left that
line with no test at all: downgrading it to the non-strict verify kept the whole
attest suite green. Split the signature check into verify_sig_with_key so a test
can drive it directly with a weak key. Check order in verify_signature is
unchanged; only the seam is new.

Also from review:
- auth/mod.rs sent a did:key that failed on its own key material away with
  "only did:key is supported in alpha", the same misleading hint the gl MCP
  tool was just fixed for. Made it conditional on is_did_key so the two
  parallel surfaces say the same thing.
- The guard comments claimed a small-order point converts to the all-zero
  Montgomery u. That holds for order 1 and 2 only; order 4 and 8 are
  annihilated by the scalar clamping instead. is_weak covers the whole torsion
  set, and mixed-order points are correctly still accepted.
- did.rs doc comment now states the new rejection.
- encrypt.rs: extracted the repeated weak-key fixture into a test helper and
  switched to the file's existing Err(anyhow!(..)) rejection idiom.
- gl tests derive the did:key discriminator from a real parsed Did instead of
  hardcoding the bool, so inverting it at the call site now fails.

* fix(attest): cap the did:key method-id before decoding, mirroring core

The parser here mirrors gitlawb-core's small-order rejection because it cannot
call into it (gitlawb-core is a dev-dependency of this crate). It was mirroring
only one of the two guards that sit three lines apart in the function being
copied: core also caps the method-id at 64 bytes BEFORE the multibase decode,
because base58 decoding is quadratic in its input.

signer comes off an attacker-supplied attestation and verify_signature reaches
this parser after computing the JCS bytes, so an oversized method-id bought a
large decode for a short request. An ed25519 did:key method-id is a fixed 48
characters, so the bound is slack rather than a behavior change.

The test asserts the length error specifically rather than any error: a
multibase error there would mean the cap ran after the decode and bought
nothing.

* fix(node): surface stored envelopes sealed to a now-unresolvable recipient

plan_seal short-circuited on a matching stored tag before resolving the
recipient set, so a blob sealed to a small-order did:key before the
choke-point guard landed kept its old CID and was silently treated as
unchanged. Resolve the set on the match and surface a now-unresolvable
reader as SkipUnresolvableStored so the operator sees the exposure.

* fix(attest): anchor VerifiedAttestation.signer to the key that verified

Registry::verify discarded the VerifyingKey returned by verify_signature
and copied the artifact's signer string into the result. The parser is
canonical so the strings agree for honest artifacts and a mismatched field
already fails verification, but the consumer-facing value now provably
comes from the key that verified rather than a raw artifact field.

* fix(node): bound the echoed DID in the unresolvable-did error and pin the hint branches

The keyid is unauthenticated request input and was reflected into the
response message at full length. Bound the echo to 96 chars and extract the
two-branch hint selection into a testable helper with both branches pinned.

* test(gl): build the weak did:key fixture from its identity-point bytes

The hardcoded WEAK_DID_KEY constant trips secret scanners, which cannot
tell a public DID from an API key. Deriving the DID from the compressed
identity point states why the fixture is weak and matches the house pattern
used in the core, attest, and node crates. ed25519-dalek is added as a
dev-dependency (it was already a workspace dependency).

* fix(core): skip a low-order ephemeral on the envelope open side

open_blob built the X25519 box from the header-supplied eph with no
small-order check, so a crafted entry forced the all-zero shared secret and
unwrapped for any reader. Skip entries whose eph decompresses to a
small-order point; honest entries in the same envelope still open.

* fix(core): test the exchange result, not the ephemeral's encoding

The open-side guard decompressed the ephemeral to Edwards and asked
is_small_order, treating a failure to decompress as safe. That reports safe
for any low-order input that is not a valid Edwards encoding. Measured across
the seven standard low-order encodings: six caught, u = p-1 missed. Its
exchange with any reader is still the all-zero shared secret, so an entry
built on it unwraps for everyone and the attacker controls the plaintext.

Check the result instead: an ephemeral is rejected when the exchange with
this reader's own scalar is all-zero. That is the property the guard exists
to enforce, and it holds for every low-order input regardless of encoding.

The new must-not test needed two attempts. The first passed with the guard,
without it, and with the original pre-fix guard alike, because a blanket
`use chacha20poly1305::aead::Aead` in the test resolved the wrap's encrypt
through a different aead version than open_blob decrypts with, building a
ciphertext that could never open for any reason. Every call in it now names
its trait.

* docs(core): anchor open-side guard comments to exchange-result invariant

The seal-side comment still named is_low_order_montgomery and the
low-order vector test doc claimed a decode-path filter that does not
exist. Both now describe yields_all_zero_shared_secret.

* test(core): use generated keys in poisoned-envelope fixtures

Replace hard-coded test scalars that CodeQL flags as cryptographic
material. The all-zero shared-secret attack does not depend on which
scalar builds the attacker box.

* test(core): use Default::default() for zero-point fixtures flagged by CodeQL

CodeQL flags [0u8; 32] literals that flow to a cryptographic key
argument, even in test fixtures where the value is the all-zero
Montgomery point (a public low-order test vector, not a secret).
Default::default() produces the same bytes without a literal, clearing
the alert without changing behavior.

* test(core): generate body nonces with OsRng in poisoned-envelope fixtures

CodeQL flags hard-coded byte arrays used as nonces, even in test
fixtures where the value is a fixed test vector. Generate with OsRng
instead, matching the production seal path.

* chore: sync Cargo.lock after merging main (ed25519-dalek edge for gl)
2026-11-05 01:49:28 +00:00
beardthelionandKevin Codex 206e0827ad fix(node): bound replay of authenticated gossip ref-update events (#334)
* feat(node): add the freshness check and replay guard, not yet wired

Builds the two layers that bound replay of an authenticated ref-update, with
nothing calling them yet; the ingest path is wired in the next commit.

The freshness check is deliberately two-directional rather than an absolute
delta: an abs() window admits an event stamped up to the window ahead and pins
its seen-set slot until the clock catches up. Producers were enumerated before
settling the unparseable arm; the sole production publish site emits RFC-3339,
so an unparseable timestamp is refused rather than admitted.

The replay guard keys on SHA-256 of the canonical signing bytes, not the raw
wire bytes, because one signature verifies against many encodings and only the
canonical form collapses them to a single key. A golden digest is frozen for
the pre-version artifact, and the same constant is asserted for its v-injected
twin, so the collapse is pinned rather than described. Reservations settle
through a drop guard so only a confirmed entry outlives the ingest call, which
keeps a transient write failure from permanently burning an event's slot.

Replayed and StaleTimestamp are separate outcomes because they diagnose
different conditions, a mesh replay against a broken clock or a healing
partition, and folding them would be the same observability lie the unsigned
shed variant already exists to avoid.

* fix(node): bound replay of authenticated gossip ref-update events

Wires the freshness check and the replay guard into ingest, immediately after
signature verification and above the author debit. The lower placement, just
above the writes, stops the duplicate row and the duplicate sync but still lets
every replay drain the victim author's budget, which is the harm the defect
names: a captured signature replayed 500 times empties the victim's window and
their next genuine push is refused.

The guard runs on the verified path only. Unsigned event bytes are predictable,
so applying it there would let an attacker pre-send a victim's expected event
and have the genuine one dropped as a replay, which is a censorship primitive
rather than a defense.

A replay flood still costs a parse and one Ed25519 verify, because the guard
has to sit below verification for the reason above. What it removes is the
peer_exists round trip, the victim's author debit, the ref-update row and the
sync enqueue.

The existing author-budget test signed one event and ingested the same bytes
five hundred times, so it had to re-sign per iteration to keep exercising the
budget under a shared guard. Giving it a fresh guard per call would have kept
it green while gutting the property it exists to prove. Its over-budget probe
needed the same treatment, since the replay gate sits above the author gate and
would have refused the burst's last bytes before the budget assertion ran.

Both saturation tests now take a shared lock so each keeps an exact assertion
on a process-wide counter; a lower bound would stay green if the fail-open
branch ever double-counted.

* fix(review): bound the saturated guard's rescan and the log it writes

At capacity the inline sweep ran on every event, so a full O(capacity) retain
happened under the lock once per message and reclaimed nothing when nothing had
expired. The guard that bounds replay became a CPU amplifier in exactly the
state an attacker drives toward. The sweep is now rate limited to once a second;
the periodic sweep still reclaims on its own cadence, so only the redundant
rescans go away.

An unparseable timestamp was echoed into the refusal detail verbatim. That value
is attacker-controlled and arbitrary length, so it reached a warn! as both a
log-injection and an unbounded-size sink. Only that arm needs sanitizing; the
other two ran through the parser first.

The capacity rationale cited a count of registered DIDs, which this same file
says elsewhere an attacker mints freely through the announce path, so it was not
a bound at all. It now cites the bound that is real: reaching saturation costs a
hundred thousand durable rows and as many sync enqueues inside one retention
horizon, which the database makes loud.

ingest_now was read twice per ingest while its own doc comment claimed the two
layers share one reading, which is the invariant the retention derivation rests
on. Now read once and passed to both.

Also: the Unparseable outcome is driven through ingest rather than only as a
pure function, the periodic sweep is observable without a live swarm, the
restart exposure is written down where the rest of the tradeoffs already are,
and the saturation-counter lock covers every test that can reach Saturated
rather than the two I first found.

* test(node): close the replay guard's remaining proof gaps, and fix the sibling window

Seven tests for the six gaps a review found. The reservation's release path was
reasoned rather than executed: the drop guard was only ever proven through an
early refusal, while the case its own doc comment names, a transient write
failure burning the event's slot, was never driven. Both write directions now
are. An expired entry at capacity must be replaced in place rather than answer
Saturated, confirm on an already-swept entry is pinned as a deliberate no-op,
and the single-critical-section shape is now driven concurrently rather than
asserted sequentially. Restart behavior was documented but untested; a fresh
guard readmits a seen event and the freshness window still bounds it, which is
the composition that makes the restart exposure finite.

check_created in gitlawb-core used a symmetric abs() window, so a request
stamped 299 seconds ahead was accepted and a signer could roughly double a
signature's effective validity by stamping forward. It is now two comparisons
like the gossip path, 300s late and 60s early, with the error naming the
direction so a fast peer and a slow one need different operator action. There
was no future-direction test at all; there are now five covering both. Every
caller was checked and none depended on the symmetry.

Adding a freshness window obliges auditing the siblings, which is how this one
surfaced: the repo argued both ways in two files for a week.

* fix(core): stop an attacker-supplied created timestamp from overflowing the window check

created is parsed as an unrestricted i64 straight from the Signature-Input
header, so the sender picks it. At i64::MIN the past-side subtraction overflows,
which panics a debug build and wraps a release one into a value that can read as
inside the window. Confirmed by execution before the fix: 'attempt to subtract
with overflow'.

Saturating subtraction answers correctly at both ends, since a timestamp that
far out is refused by whichever side it saturates toward. A test drives all four
extremes of the type and asserts each is refused by a named direction; reverting
to plain subtraction reddens it on the overflow.

The symmetric abs() form this replaced had the same hazard, so splitting the
window into two comparisons did not introduce it, but it did not remove it
either. Found by a cross-family review pass after six same-family reviewers and
I had all read the line.

* fix(review): reconcile the replay guard with the unsigned-admission split

Integration only, from rebasing this branch onto #325's current head. #325 now
returns IngestOutcome::UnsignedAdmitted where it used to return Accepted for an
unsigned rolling-upgrade admission, and this branch predates that split.

Three sites. The seen-set bypass test asserted Accepted on both unsigned
deliveries and on the stale-timestamp case; both now expect UnsignedAdmitted,
and the properties under test are unchanged, that unsigned bytes are admitted
twice rather than deduplicated and that the freshness window does not reach
them. The warn-only-on-admission test's budget-spent case gained the
ReplayGuard argument the signature now takes, with a fresh guard because that
case drives an unsigned event the replay block skips.

The ingest match also grows an arm rather than changing one: `None if unsigned`
returns UnsignedAdmitted without settling a reservation, since the replay block
is gated on `verified` and an unsigned admission never holds one.

* fix(node): render rejected gossip timestamps bounded before logging

DateTime::parse_from_rfc3339 accepts arbitrarily many fractional-second
digits, so a signed timestamp can parse successfully and still carry
megabytes of attacker-chosen wire bytes into the TooOld / TooFarFuture
refusal details, which the swarm loop logs verbatim. Only the
parser-error branch sanitized. The root cause was treating successful
parsing as proof the wire representation is safe to expose.

The TooOld and TooFarFuture variants now carry the PARSED instant and
the detail renders its canonical to_rfc3339() form - bounded by this
build's formatter and control-free by construction - while the
unparseable arm keeps sanitize_for_log on the wire string. All three
arms go through one freshness_refusal_detail helper so the invariant
has a single home, plus a regression test driving an overlong but
parseable fractional stamp through both directions.

* test(node): pass the replay guard to the post-fork shed tests

The two ingest tests added on the target branch after this PR forked
(an_over_budget_author_is_shed_without_a_peer_lookup and
the_early_author_check_does_not_track_an_unseen_did) still call
ingest_ref_update with the pre-guard signature. Thread a fresh
ReplayGuard through each call, matching every other test site.

* test(node): drive the live-mesh publish and refusal path end to end

Two real swarms on QUIC loopback with real signatures and real Postgres:
a signed ref-update from one node is admitted and stored by the other, a
re-delivery of the same signed content under a malleated wire encoding is
refused by the replay guard, and an unsigned event is refused under
require_signed. Both refusals are asserted in the warn stream, and a
control event landing after them proves the mesh still delivers.

This closes the seams ref_update_publish_args names as needing a live
swarm: the PublishRefUpdate select! arm dispatching to sign+publish, and
require_signed reaching the receive path. It needs a test-only command
variant, PublishRawRefUpdate, since the production publish path signs and
serializes itself and can never put a malleated or unsigned event on the
wire.

Still uncovered and named in the test's doc comment: the run_ingest_sweep
select! arm on its 300-second interval, and the main.rs half of the flag
wiring above p2p::start.

* fix(node): harden the gossip rejection path against unauthenticated bytes

Two findings on the pre-auth reject path:

- An unsigned event under require_signed was resolved and slug-validated
  before the flag refused it, so a hostile flood bought DID resolution and
  a rejection reason built from attacker-chosen fields under only the
  loose pre-parse brake. The unsigned refusal now runs immediately after
  the version gate, before any field is read.

- The swarm loop logged IngestOutcome::Rejected and WriteFailed reasons
  verbatim, and several producers embed wire fields (the claimed node_did
  in resolve failures, for one). A signed event carrying a control-bearing
  node_did reached the operator log with escape sequences intact and no
  length bound. Both warn sites now render through sanitize_for_log, so
  every present and future Rejected producer is bounded and control-free
  at the one sink.

The live-mesh test now also publishes a signed event with a 4096-char
control-bearing node_did and asserts the warn stream shows the refusal
with no escape byte and no run past the sanitizer ceiling. A unit test
pins the new ordering by asserting the exact fixed refusal for an
unsigned event with a hostile node_did.

* fix(node): dedupe the ref-update row on the replay key, not on a random id

The reservation releases on a failed write so a republish can repair the
half that never landed. But each ingest minted a fresh row id, so a retry
after a partial failure (row stored, queue write failed) stored the same
signed content a second time. A replay in a degraded state could repeat a
landed side effect until a budget refused it.

The row id for a verified event is now derived from the replay key, so the
retry's insert dedupes on the existing ON CONFLICT instead of duplicating.
The queue entry still retries, which is the repair the release exists for.
Unsigned events keep a random id: they carry no replay key by design, and
deduplicating unauthenticated content would let an attacker pre-send a
victim's expected event and have the genuine one dropped.

Two existing tests pinned the duplicate write and now assert the deduped
shape: the partial-failure republish repairs the queue entry while the
row stays at one, and the restart readmission's residual effect is a
duplicate queue entry (a refetch), not a second record. The new test
asserts both the retry reaches the writes (WriteFailed, not Replayed) and
the row count stays at one.

* test(node): pass a libp2p identity to p2p::start in the gossip delivery tests

* ci: trigger the PR checks on the retargeted base

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-04 23:04:03 +00:00
421f03ac46 fix(node): reject revision syntax in issue ids and drop service passthrough (#521)
* fix(node): reject revision syntax in issue ids and drop service passthrough

resolve_issue_id spliced the request-path issue id straight into a git
revision (`cat-file -e refs/gitlawb/issues/<id>`) and a for-each-ref
pattern, and on an exact match echoed the raw input back as the
canonical id. Revision and glob syntax such as `<uuid>^{blob}`, `~0`, or
`*` therefore resolved to an existing issue and was returned to callers
(and stored by create_issue_comment) as if it were the id. Accept only
ASCII alphanumerics and '-' and report anything else as not found.

service_to_command fell through to returning the caller's service string
for an unknown service. Every caller validates first, but the mapping
now yields only the two fixed subcommands and errors otherwise, so an
unrecognized value can never reach git's argv.

Addresses CodeQL rust/command-line-injection alerts #83 and #80.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(node): cover service_to_command allowlist and passthrough rejection

Direct unit test ensures unknown services and option-shaped inputs cannot
map to git argv even if HTTP-layer validate_service is bypassed in future.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 22:44:09 +00:00
JATMNandClaude Opus 5.5 e2ae21795a chore: rebrand user-facing gitlawb references to Twigpine (#520)
* chore: rebrand user-facing gitlawb references to Twigpine

Rename the product name in docs, README, CLI help/output, MCP tool
descriptions, macOS app strings, installers, npm metadata and license
headers from gitlawb/Gitlawb to Twigpine. Point GitHub links at the
renamed Twigpine org (and fix the release workflow's repository guard,
which still matched Gitlawb/node), and switch the public node hosts and
website/install URLs to twigpine.com.

Protocol and operator identifiers are intentionally unchanged
(did:gitlawb, gitlawb://, signing domains, GITLAWB_* env vars, crate
and binary names, X-Gitlawb-* headers, icaptcha and peer-network URLs);
they need a follow-up migration.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: point image pulls at ghcr.io/twigpine/node and fix macOS DMG name

The release workflow publishes the container image to
ghcr.io/${GITHUB_REPOSITORY,,}, which is ghcr.io/twigpine/node since the
org rename; the old ghcr.io/gitlawb/node path no longer resolves for
anonymous pulls. Point the RUN-A-NODE docker run example, the OSS
readiness audit's claim about it, and the macOS app's generated compose
file at the published path.

The README listed the macOS DMG as "Twigpine Node.dmg", but
build-macos-app.sh writes GitlawbNode-macOS.dmg (kept on purpose), so
document the real file name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(infra): keep the AWS security group description to avoid replacement

`description` on aws_security_group is ForceNew, so renaming it forced a
replace of the in-use group on the next apply of an existing deployment.
Restore the original text and note why it must stay until a separate
infra migration.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(macos): regenerate the app's compose file instead of treating it as a repo's

Config.repoPath fell back to the data directory when no repo clone was
found, and DockerCompose then picked up the compose file it had generated
there as if it belonged to a repo. The generator therefore only ran on
first launch, so upgraded installs kept their original compose, including
the old ghcr.io/gitlawb/node image, which no longer serves anonymous pulls.

repoPath now returns nil when there is no clone, and the app regenerates
its compose file whenever no repo compose is in use. Project directory and
named volumes are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(infra): default the AWS node image to ghcr.io/twigpine/node

release.yml publishes to ghcr.io/${GITHUB_REPOSITORY,,}, which is now
ghcr.io/twigpine/node; ghcr.io/gitlawb/node denies anonymous pulls, so the
default made new deployments fail to start and the upgrade command fail
on existing ones. On an existing deployment `terraform apply` only updates
the upgrade SSM document in place (user_data drift is ignored); the new
image is pulled on the next upgrade_command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(macos): keep operator edits to the generated compose, migrate only the image

e04efe4 regenerated the app's compose file on every access, overwriting
edits operators make to pass settings the generated environment block
omits (e.g. GITLAWB_OPERATOR_STRICT_MODE). Generate the file only when it
is missing; when it exists, keep it and rewrite only references to the
retired ghcr.io/gitlawb/node image, keeping tags and digests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(macos): only migrate complete ghcr.io/gitlawb/node references

Without a left boundary the migration also rewrote the retired path when
it appeared inside a longer reference, such as a registry mirror
(registry.example.com/ghcr.io/gitlawb/node), pointing it at a path the
mirror may not have.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 21:58:29 +00:00
beardthelionandKevin Codex ed76adfe29 fix(node): fence a repo publish on the write attempt that owns it (#285)
* fix(node): close the advisory-lock probe's session if it is dropped mid-acquire

A cancelled .await does not cancel an already-sent SQL statement, so a
pg_try_advisory_lock whose future is dropped still takes the lock
server-side while the caller abandons the result, leaving nothing to
release it. The connection then returns to the pool holding the lock and
wedges that repo until sqlx recycles the session.

Introduce LockProbe, which owns the connection across the in-flight
try-lock and closes it in its own Drop if it is still held. close_on_drop
is a one-way setter, so the arming lives in Drop rather than being set up
front and cleared on success; disarming is Option::take, which is what
into_conn does once an acquire is actually observed. This is now the only
place that issues pg_try_advisory_lock.

The committed gate drops a probe without taking its connection, which is
the state a cancellation leaves behind, and polls a standalone observer
until the lock frees. Deterministic on purpose: the timing sweep that
found this window leaks roughly 1 in 600, which is not something a CI
gate can rest on. Observed RED before this change with the lock still
held for the full 10s window.

Refs #279

* fix(node): add a dedicated, lazily-connected advisory-lock pool

Pinning a connection for the lock's lifetime is only safe if those
connections come from somewhere other than the pool serving ordinary
request handlers, otherwise a push burst starves every other query. Add
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS (default 32) and a Db::lock_pool
builder, with the sizing tradeoff documented on the field and in
.env.example: every in-flight write pins one connection here, so the
value is a hard ceiling on simultaneous writes node-wide.

The pool connects lazily on purpose. The main pool must connect eagerly
because it runs migrations, which is why it needs connect_db_with_retry's
backoff and degraded-server handoff; that function is not a generic retry
helper and the lock pool is built well after the db-ready handoff has
already resolved. A lazy pool has no startup work, so it adds no new way
for the process to fail to boot and needs no second copy of that
machinery. If Postgres is unreachable when the first write arrives, that
write fails on the pool's own acquire timeout, like any other
database-backed request.

Pure configuration, so no proof-first cycle: the knob is covered by a
parse/default/reject-zero test. Db::lock_pool has no caller until the
guard wiring lands, hence the temporary dead_code attribute.

Refs #279

* fix(node): hold the lock-owning connection in RepoWriteGuard (#279)

Postgres advisory locks are session-scoped: only the backend that took one
can release it. acquire_write took the lock through fetch_one(&pool) and
release unlocked through execute(&pool), two independent checkouts, so the
unlock usually landed on a session that held nothing and returned false.
Measured on main: two writers on one node and the same repo BOTH acquired,
50 of 50 sequential cycles leaked, and 100 writes left 100 orphaned
advisory locks on the server.

The guard now owns the PoolConnection that took the lock, drawn from the
dedicated lock pool, and releases on that same session. The retry loop
probes through LockProbe so a cancellation mid-acquire cannot strand the
lock, and hands the connection back before each backoff so a spinner on a
contended repo does not pin a slot while idle.

Pool exhaustion is deliberately not retried. It is a different condition
from lock contention, and retrying it would spend all 60 attempts on a
capacity problem unrelated to this repo while reporting it as someone else
holding the lock.

Both #279 acceptance tests were observed RED first: the exclusion test
admitted the second writer, and the leak test reported 1 lock held where 0
was required. Both GREEN after. Full crate suite 516 passed.

Db::pool() is removed because this change was its only caller.

Refs #279

* fix(node): free the advisory lock when a write guard dies without releasing

A guard can exit without reaching release(): an early ? on the pre-write
download, a panic, or an axum handler future cancelled when the client
disconnects. Its session still holds the lock, so returning that
connection to the pool would block every future write to the repo until
sqlx recycles it, which on the 0.8.6 defaults is ten minutes idle or
thirty minutes lifetime. Close the session instead and let Postgres free
the lock at session end.

One hazard found by writing the teardown test rather than by reasoning:
PoolConnection::drop spawns onto the runtime for both closing and
returning, and panics outright when no runtime handle exists. That panic
would fire inside a Drop and abort the process during unwind. It is not
introduced by this commit, it comes with owning a PoolConnection at all,
but this is where it becomes reachable. So Drop checks for a runtime
first and, with none, leaks the handle deliberately rather than panicking:
the process is already exiting and socket teardown ends the session, which
is what frees the lock at exit anyway.

Observed RED before the fix, with the lock still held for the full 10s
poll window against a standalone observer on a no-reap pool, and the
teardown case panicking in sqlx-core connection.rs:208. Proven
load-bearing after: neutering the close_on_drop call turns the drop test
RED again. The must-not case (a released guard reuses its backend pid
across four writes on a pool sized 1) passes in both states, so the
signal is specific to the abandoned-guard path.

Full crate suite 519 passed.

Refs #279

* fix(node): read the advisory unlock's result instead of discarding it

pg_advisory_unlock reports "you did not hold this lock" as a false RETURN
VALUE plus a server WARNING, never an error, so let _ = execute(...) could
not distinguish a real release from a no-op. Three blindnesses stacked in
those four lines: execute discards the row, the boolean lives in the row,
and let _ discarded the Result too.

Read it through fetch_one into (bool,). A false means this session's lock
state is not what we believe it is, so the connection stays in the guard
for Drop to close rather than being handed back to the pool as clean. Only
a confirmed unlock returns it. A query error gets the same treatment, since
the lock must not outlive a session we can no longer reason about.

Note this is the only unlock site: the pre-write download's error path
returns through the guard, so Drop covers it and there is no second place
to keep in sync.

RED before: the connection came back on the same backend pid after an
unlock that returned false. GREEN after, and proven load-bearing by
treating false as success, which turns it RED again. The must-not case (a
normal release still reuses its backend) passes in both states, so this
does not over-close the happy path.

Full crate suite 520 passed.

Refs #279

* fix(node): bound the object-storage transfers that run under the write lock

Two transfers happen while the per-repo advisory lock is held: the archive
download inside acquire_write, which runs after the lock is taken and
before the guard exists, and the upload inside release. Both were free
before the guard pinned a lock-pool connection, because the lock's
connection went back to the pool immediately. Now an unbounded stall holds
a lock-pool slot for its whole duration, so enough concurrent stalls deny
every write on the node with no reaping path. Add
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS (default 300) and route both
through it.

A timed-out upload is UNKNOWABLE rather than failed, so the timeout arm
takes no compensating action: the PUT may well have landed. The lock is
released either way, which trades a narrow last-writer-wins window for not
wedging the repo behind a stalled transfer. Deliberate, and stated here
rather than discovered later.

Note what is NOT bounded by this knob: acquire_fresh's download. It runs
before any lock is taken, so it holds nothing. An earlier revision of this
change put the bound there by mistake, because both functions contain an
identical download call and the first match won.

Two disclosures.

Coverage: the committed tests cover the bound mechanism, not the wiring.
Driving a genuinely stalled transfer through acquire_write needs either the
object-store abstraction (out of scope) or a process-global
AWS_ENDPOINT_URL_S3 mutation, which would make the suite order-dependent
under the concurrent runner. That a stalled transfer is bounded is
therefore verified by reading, not by execution.

Behavior: on timeout with a local copy present, the download falls into the
pre-existing self-healing fallback and the write proceeds against that
local copy. This widens the conditions reaching that path from
corrupt-or-unreachable to include merely-slow, so a stale tree could now be
written and re-uploaded on a slow link. Kept consistent with the existing
failed-download behavior rather than inventing new semantics here; changing
it is its own decision.

Full crate suite 522 passed.

Refs #279

* fix(node): authorize before taking the write lock in close_issue

close_issue acquired the per-repo advisory lock, then ran the
owner-or-author check, then returned 403 with the lock still held. That was
harmless only because the lock excluded nothing. Making it work, as the
preceding commits do, turns this ordering into a denial primitive: any
caller with repo read access could take the write lock on demand and be
refused the write, while a legitimate writer burned its 60-attempt retry
budget against a lock held by someone with no write authorization. On a
public repo that is every permissionless identity. This series creates the
exposure, so it closes it in the same series.

The owner check is cheap and moves above the lock outright. The author
fallback needs the issue's git-JSON blob, since there is no author column,
so it now reads through acquire() rather than acquire_write(): an issue's
author is set at creation and never changes, so reading it outside the lock
races nothing, and only the mutation needs exclusion.

Two deliberate behavior choices. A non-owner whose authorship cannot be
established gets 403 rather than 404, so this route does not tell an
unauthorized caller whether an issue exists. And the owner's existing
404-for-a-missing-issue path is preserved by re-reading under the guard,
which the mutation wants anyway.

Swept the other three acquire_write sites: merge_pr owner-gates at :200
before acquiring at :214, the repo write path checks did_matches at :916
before :933, and create_issue's read gate is legitimate because that caller
IS authorized for the action it performs. close_issue was the only one with
the wrong order.

RED before: with an independent session holding the repo's lock, a
stranger's request sat in the retry loop until the 3s deadline fired
(Elapsed), which is the wedge itself. GREEN after, refused immediately.
Proven load-bearing: disabling the pre-lock refusal restores the Elapsed.

Full crate suite 523 passed.

Refs #279

* fix(node): address the review findings on the advisory-lock series

Seven fixes from a seven-reviewer pass. Eight of the nine substantive
findings were defects introduced by this branch, and two contradicted
claims made in its own earlier commit messages.

P0, the worst of them. The under-lock download's timeout was folded into
the same Err as a corrupt archive, so it fell through to the
use-the-local-copy fallback and the write proceeded. Two ways that
destroys data: we do not know whether we hold the latest tree, so
re-uploading can overwrite another node's newer archive; and the abandoned
download's extraction runs in an uncancellable spawn_blocking that ends in
remove_dir_all + rename over the same path, so git would have been running
against a directory a background task was about to delete. Now the
Option is branched rather than collapsed: a genuine fetch error keeps the
local-copy fallback, a timeout refuses the acquire and lets the guard's
Drop free the lock.

LockProbe closed its connection on ordinary contention, so a 60-attempt
spinner tore down 60 backends -- the exact behavior an earlier commit
claimed it avoided. The test written to guarantee that asserted the
HOLDER's lock count, which cannot observe the probe's own connection, so it
passed throughout. The correct predicate turned out to be narrower than the
first attempt at this fix: not "we saw an answer" but "we positively know
nothing was acquired," because a true answer means the lock IS held and
dropping without handoff leaks it exactly as a cancellation would. The
first attempt reopened U1's leak and U1's gate test caught it.

tigris.exists() ran unbounded inside the lock-held span, so the knob's
promise was not kept and the docs were wrong about the shape. The HEAD and
the download now share one budget, so worst-case occupancy is one budget
per span rather than two.

close_issue's pre-lock author read used acquire(), whose fast path returns
on a cached directory and never contacts object storage, so on a multi-node
deploy an issue author would be refused their own issue. Now acquire_fresh,
which refreshes without taking the lock -- the property the comment
already claimed. That comment also asserted authorship is immutable, which
a reviewer disproved by force-pushing a forged blob at refs/gitlawb/**;
reworded to the real justification (a pre-check whose mutation re-reads
under the guard). The underlying pushability is pre-existing and larger
than this branch.

The retry loop had no wall-clock cap, reaching ~360s against a 120s proxy
idle timeout that was itself lowered from 600s after held connection slots
caused a production outage. Capped at 90s, with the bail message naming the
real bound.

All four acquire_write call sites stringified their error, destroying the
anyhow downcast that error.rs documents explicitly ("without this, every
database outage surfaces as a 500 instead of a 503"). PoolTimedOut is in
that 503 set, so a saturated lock pool told clients not to retry something
transient. Now propagated.

Tests: six that the plan required and the first pass never wrote. The R4
pool-isolation test (named in the plan as proving the pool split, entirely
absent), close_issue's owner and non-owner-author twins (INV-21c, a
two-principal gate with only its deny arm covered), a waiter-does-not-block-
an-unrelated-repo case, and a config test for the transfer knob. The
runtime-teardown test no longer returns green when DATABASE_URL is unset.

Drop's no-runtime branch now detaches via leak() instead of mem::forget:
PgConnection has no Drop impl, so the socket closes synchronously and the
lock frees immediately rather than at process exit.

Evidence. The probe predicate is proven load-bearing in BOTH directions:
forcing always-close reddens the churn regression, forcing never-close
reddens the cancellation gate, and only the correct predicate satisfies
both. The author twin reddens when the fallback is removed.

One honest limit: the author twin does NOT redden when acquire_fresh is
reverted to acquire. With tigris disabled in tests the two calls are
identical, so that fix is correct by reading, not by execution -- the same
seam that leaves the transfer bound's wiring untested.

Full crate suite 529 passed, clippy -D warnings clean.

Refs #279

* fix(node): log lock-pool saturation in the request path, not via readiness

Completes the shed-in-path half of the availability story. F7 already made a
pool timeout a retryable 503 by letting the sqlx downcast through; this adds
the operator-facing half, with the pool's own size/idle counters so an
incident can distinguish "the pool is full" from "the database is gone"
without reproducing it.

Deliberately NOT a readiness probe. /ready gates Fly routing with no
fail-open, so failing it on a saturated pool would pull this node's READS
out of service too and push its write load onto peers carrying the same
load. That is the downward spiral AWS's health-check guidance names and the
SRE Book documents independently. A lock-pool readiness probe would also add
nothing on the reachability axis, because both pools are built from the same
database_url, so the existing app-pool ping already answers it.

The error is still returned via .context() rather than replaced, because
anyhow preserves downcastability through context layers and the 503 mapping
depends on it.

Cost is bounded by construction: one line per failed acquire, and a failed
acquire has already paid a multi-second pool timeout.

Refs #279

* fix(node): refuse a write when object storage is unknowable, and shed contention as 503

Four defects a seven-reviewer pass found in the previous two commits, three of
them cases where one arm of a match contradicted its neighbour.

The under-lock refresh read a failed Tigris HEAD as "no archive"
(`exists().await.unwrap_or(false)`), skipped the refresh, and wrote against a
possibly-stale tree, then re-uploaded over it. That is the outcome the timeout
arm twenty lines below explicitly refuses. A failed HEAD and a failed download
leave us knowing different things, so they no longer share a branch: only a
download failure after a successful HEAD establishes that the local copy is a
sound thing to fall back to and re-upload. A HEAD failure now refuses the write.

Lock contention that ran out the acquire deadline surfaced as a 500 carrying the
owner slug and repo name in the client-visible body, while pool exhaustion
fifteen lines up returned a deliberate 503. Contention is transient and ordinary,
so it now maps through a typed `RepoBusy` to a retryable 503 with a fixed body;
the detail stays in the log at the raise site.

`lock_not_taken` was assigned after the try-lock answered, so an error left a
previous `true`-derived value standing and could return a lock-holding session to
the pool. It is now cleared before the statement is sent.

`leak()`'s no-panic property in the guard's no-runtime Drop branch depended on
`min_connections == 0` without saying so; a future tuning change would have
silently re-armed a panic inside Drop. Made explicit with the reason.

Two tests that did not bind:

  - `waiter_on_one_repo_does_not_block_another` proved only that two lock keys do
    not collide. It now samples the pool counters across more than two backoff
    cycles. RED at 0/50 samples with `drop(probe)` moved after the sleep, the
    exact defect it names, which the previous version survived.
  - the exhaustion test's `Ok(Err(_)) | Err(_)` was satisfied by its own outer
    timeout. It now requires `PoolTimedOut` and asserts the slots are accounted
    for. RED with the pool sized N+1.

`contended_acquire_sheds_as_repo_busy_not_internal_error` is new and drives the
deadline path for real; the deadline became a field so it does not wait 90s. RED
at 500-vs-503 with the downcast removed.

The acquire deadline's docstring claimed a total under the 120s proxy idle
timeout, which the 300s under-lock transfer bound in the same function
contradicts. It bounds the wait only, and now says so. The backoff is clamped to
the remaining budget.

* fix(node): re-authorize close_issue under the guard instead of only re-checking existence

The guarded re-read matched `Ok(Some(_))` and discarded the blob, so the
authorization decision rested entirely on the pre-lock read. A comment above
claimed the opposite. That is not a narrow window: `acquire_write` re-downloads
the archive after locking, so the tree that gets mutated is routinely not the one
the author was read from. With owner-push enforcement defaulting to false and
branch protection covering only `refs/heads/*`, `refs/gitlawb/issues/*` is
pushable, so a forged author blob landing between the two reads was honored.

The blob is already in hand under the guard, so re-asserting owner-or-author
costs a deserialize. A non-owner whose issue has vanished gets 403 rather than
404, matching the pre-check's existing refusal to reveal existence.

`owner_can_still_close_after_the_reorder` seeded the owner as their own issue's
author, which made it unable to fail: with the owner check disabled the author
fallback granted the close and the test stayed green. It now seeds a third party,
so only the owner arm can grant. RED with `is_owner = false`.

Also drops the claim that the author twin covers the acquire-vs-acquire_fresh
distinction. `RepoStore::for_testing` hardcodes `tigris: None`, so the two calls
are identical in every test here and reverting that line leaves the twin green.
Separating them needs an object-storage seam. Stating the gap beats asserting
coverage that does not exist.

* style(node): rustfmt the new contention test

Whitespace only; cargo fmt --check gates the push.

* fix(node): shed an unrefreshable write as a retryable 503 with a fixed body

The two refusal arms added for the under-lock transfer bound returned bare
anyhow errors, so AppError's From impl (which downcasts only sqlx::Error and
RepoBusy) landed them in Internal: a 500 internal_error whose body was the
error string. Two defects in one. A transient object-storage failure told the
client not to retry, and the timeout arm's message interpolated the owner slug
and repo name straight into the response body, contradicting the fixed-body
policy the RepoBusy arm sets six lines above it.

RepoUnavailable follows RepoBusy exactly: a fieldless type raised with the
operator detail in a context string, downcast to its own rung, mapped to a 503
whose body interpolates nothing. The detail stays in the log at the raise site.

The timeout arm logs at error! where its sibling logs at warn!, deliberately: a
300s stall pinned a lock-pool slot for five minutes and is the condition the
transfer bound exists to surface, so it must keep paging when the handler
classifier demotes the ordinary blip case.

* fix(node): refuse a fresh-copy write when the archive HEAD cannot be read

acquire_fresh collapsed a failed HEAD into "no archive exists" via
unwrap_or(false) and served the local copy, while the sibling path under the
lock already refused on the same condition. A push that hit a storage blip got
an advertisement built from a possibly-stale tree, uploaded a whole pack, and
was then refused by acquire_write for the reason the advertisement had already
swallowed. The authorship pre-check on close_issue read the same way, which is
an infrastructure failure resolving toward a denial.

A failed HEAD now raises RepoUnavailable, so both callers surface it as the
retryable 503 rather than a stale success. The download-failure fallback is
unchanged: a present-but-unreadable archive is still self-healed from local.

info_refs keeps its map_err for every other failure so the read path's error
vocabulary does not move; only the typed error takes the From chain.

A test-only TigrisClient constructor pointed at a closed port makes both
refusals executable, so this is no longer verified by reading. It also proves
the under-lock arm end to end, which the earlier draft had recorded as an
untestable gap.

* fix(node): log expected transient acquire failures at warn, not error

Both acquire call sites logged every failure at error, including RepoBusy,
which the raise site already logs at warn. Ordinary write contention paged.
The previous commit widened the problem: info_refs now raises RepoUnavailable
on a storage blip, so that site would have started paging on the condition
this series just classified as transient and retryable.

A classifier over the two typed refusals picks the level at both sites,
mirroring the startup path's permanent-vs-transient split. Anything it cannot
classify still logs at error, so an unknown failure keeps paging.

* fix(node): log the read failure the close_issue pre-check folds into a denial

The authorship pre-check treated a get_issue I/O error and a genuinely absent
issue as the same None, with no log line. The client answer is deliberately
identical, since a caller who cannot write must not learn whether the issue
exists, but the two are not the same event and an operator had no way to tell
a real authorization denial from a filesystem or parse failure behind it.

Splitting the arm leaves the 403 exactly where it was and makes the read
failure visible in the log.

* test(node): poll for the closed session instead of sleeping a fixed 300ms

The release-invariant test slept 300ms for a Drop-spawned close before
comparing backend pids, which is a coin flip on a loaded CI runner. It now
polls pg_stat_activity for the captured pid on a standalone connection, the
same discipline poll_until_free documents: a pooled observer would be handed
the session under measurement and hide the effect.

The conversion was checked against the failure it exists to catch rather than
assumed. Pooling the session on an unlock that returned false makes the test
fail after the poll deadline, not hang, and no_reap_pool disables idle timeout
and max lifetime so nothing but the close under test can retire that backend.
A generous deadline would have been the same defect as the sleep.

* fix(node): raise a failed fresh download with no local copy as RepoUnavailable

acquire_fresh already refused a failed Tigris HEAD as RepoUnavailable, so the
handler layer mapped it to a retryable 503. A failed GET on the same path still
returned a bare anyhow error, which the info/refs map_err closure stringified to
AppError::Git and answered 500. One endpoint therefore told the client a
transient object-storage blip was permanent or retryable depending on which call
failed, and close_issue's pre-check inherited the same split through its bare ?.

Raise it at the source instead of at each consumer: From<anyhow::Error> for
AppError already downcasts RepoUnavailable out of the context chain, so both
callers pick up the retryable mapping without touching either.

The new test drives HEAD 200 with GET 500, which is the exact state the refusal
is for: archive present per HEAD, GET failed, no local copy to fall back on.

* test(node): assert the second writer sheds as RepoBusy instead of timing out

two_writers_on_the_same_repo_are_not_both_admitted wrapped the second
acquire_write in an 8-second outer timeout and only checked the future had not
finished. That passes for any stall, including lock-pool saturation or a slow
CI box, so it could not tell a working shed from an unrelated hang, and it cost
8 seconds on every suite run.

Use with_lock_acquire_deadline and assert the typed RepoBusy downcast while the
first guard is still held, matching what contended_acquire_sheds_as_repo_busy_
not_internal_error already does. It now also fails loudly if a second writer is
admitted, which the timeout version could not distinguish.

* fix(node): read the close_issue author pre-check from a non-mutating snapshot

The non-owner author fallback refreshed through acquire_fresh, which publishes
into the live repo directory: its extract step removes the existing directory
and renames the new one into place. That runs with no write lock held, so any
signed non-owner could trigger a directory swap underneath an in-flight
guarded write on the same path.

read_snapshot downloads to a throwaway temp dir and hands back a RepoSnapshot
that removes it on drop, so the pre-check still sees fresh data and the live
path is never touched. download_to grows a publish flag to serve both shapes
from one path.

The wedge invariant still holds: a stranger is refused without waiting on the
write lock.

* fix(node): bound every await in the lock-acquire loop by the deadline

The remaining budget was checked before the pool checkout but bounded neither
the checkout nor the pg_try_advisory_lock query that follows it. A checkout
starting just under the deadline could wait out the pool's own acquire timeout,
and a slow query could be accepted after the budget was spent, so the advertised
wall-clock cap held only on the fast path.

Both awaits now run under the remaining budget and shed as RepoBusy when it runs
out. The probe's Drop closes its session, which cannot hold a lock it never
confirmed taking, so the query-timeout arm is a plain shed.

* test(node): add an S3 mock that really enforces conditional writes

The fence work landing next is only as good as what the tests can observe, and
a mock that answers 200 to every PUT would make the whole suite vacuous. This
one holds the object and its ETag, refuses a mismatched If-Match and an
If-None-Match "*" over an existing object with 412, and mints a fresh ETag per
successful PUT so two byte-identical archives never share a token.

Capture-then-replay is deliberate rather than parking a handler and hoping it
resumes: when tokio drops an SDK future the client can tear the connection down
and cancel the server task with it. Replaying what arrived models the arm that
matters (body fully transmitted, commit decided later) with no timing in it.

Six tests pin the semantics in both directions so a hollowed mock cannot hide.

* fix(node): stop pretending a held lock fences a late upload

The timeout arm claimed that keeping the advisory lock protected a successor
from an abandoned PUT. It does not. release takes mut self, so the guard drops
the moment it returns and Drop closes the session; measured on this branch, a
successor took the same repo's lock 5ms after release returned while the PUT
was still in flight.

The comment and warn now say what is actually true: the outcome is unknowable,
the PUT may still land, the lock releases normally, and a conditional upload is
what keeps a late publish from overwriting a successor's archive.

Two tests replace the claim. Session disposition is the observable that
separates the two shapes, so the unlock is pinned to run and be confirmed on the
guard's own session with the connection returned to the pool, checked by backend
pid. Successor admission is pinned too, but noted as not what proves the point,
since the lock frees within milliseconds either way.

* feat(node): let the storage client express and detect a conditional write

upload takes an UploadPrecondition (IfMatch, IfAbsent, Unconditional) rather
than an Option<String>, so the absent case and the deliberate no-fence case are
distinguishable at the type level and a caller cannot lose the fence by passing
None. head_etag reads the current ETag alongside exists, which keeps exists and
its other callers untouched.

A failed precondition has to be classified off the raw HTTP status: PutObjectError
models no PreconditionFailed variant, so a 412 arrives as Unhandled with nothing
useful on it. 412 is always a lost precondition and 409 is one under IfAbsent.
404 deliberately is not: no archive delete exists on this line, so a 404 on a
conditional PUT means a wrong bucket or endpoint, and reporting that as retryable
would send clients into a loop against a permanent fault.

The three background uploads outside the write guard now publish with IfAbsent.
They fire only where the archive is expected absent, and leaving them
unconditional would defeat the fence from the side: init uploads an empty bare
repo, so a push landing just before it could have its archive replaced by that
empty one. A refusal there means someone else already published the key, which is
logged as the correct outcome rather than a failure.

* fix(node): fence the release publish so an abandoned upload cannot win

acquire_write now reads the archive's ETag under the advisory lock and the guard
carries it, so release publishes conditionally on the generation it actually
refreshed from. An upload abandoned by an earlier writer no longer overwrites a
successor's archive: the store rejects it, because the ETag it was written
against is gone.

A refused precondition gets exactly one supersede-retry, never a loop. The
distinction that makes this sound is that a 412 is a definite answer, unlike the
timeout arm where nothing is knowable, and the retrying writer still holds the
lock, so whatever landed underneath was written without one and its tree is not
the authority. Two losses in a row refuse instead of escalating.

That retry is what keeps ordinary pushes working now that init publishes
create-only: a first push racing init's upload of the empty repo loses once and
then wins, rather than surfacing a 503 on the most common operation there is.

release returns a must-use outcome and the four publishing handlers propagate it
before any trust bump, webhook, or success body, so a publish the store refused
reads as a retryable 503 instead of a 201. The three release(false) sites
deliberately do not map it: they publish nothing, and a 503 there would shadow
the 403 or 404 the route means to return.

The download-failure fallback also publishes fenced now. That arm knows the
stored generation (its HEAD succeeded, only the GET failed), so publishing
unconditionally from it would reintroduce the same overwrite.

* test(node): drive the abandoned-upload race end to end, and probe the real backend

The headline test is the one that had to exist. Writer A's release is parked past
its transfer bound and returns with the outcome unknowable, B acquires and
publishes, and A's captured PUT is then replayed: the store answers 412 and B's
archive survives. The create-only variant covers the arm whose real-world failure
is silent rather than loud.

The control is what makes those attributable. With no interleaved B, an
abandoned-then-replayed PUT whose generation still matches lands. Without it the
headline would only show that replays get rejected, not that staleness is what
rejects them.

Header assertions come last in all three, so a lost fence reds on the outcome it
is about rather than on a wire-format check.

A mock cannot prove Tigris honors any of this, and the vendor requires a
Single-region or Multi-region bucket for conditional operations, so against a
Global or Dual-region bucket the fence is a silent no-op. The credentials-gated
probe checks both arms against the real endpoint and cleans up unconditionally,
including when an assertion fails, which is the case it exists to catch. It
accepts 412 or 409 on the create-only arm because both mean the precondition was
enforced and both are already classified as a loss.

* fix(node): reconcile F-series tests and test seams with the rebased guard shape

The rebase onto main carried main's #174 F-series guard tests forward. The
branch's guard replaces main's locked/released/test_pre_unlock_gate Drop
mechanics with close-on-drop (runtime) and leak (off-runtime), so two tests
that asserted the replaced mechanics are reconciled: the off-runtime disposal
test now asserts the leak observable (the slot never returns to idle) and the
detached-unlock-returns-connection test is dropped, its invariant covered by
the U-series drop-frees-the-lock gates. The pre-unlock gate seam is restored
(test-only) so the mid-unlock cancellation tests keep their deterministic
park. ipfs.rs tests are updated to the branch's sync test-client constructor
and 4-arg RepoStore::new.

Refs #279

* fix(node): address the 2026-08-10 review round on the advisory-lock series

- P1: serve the receive-pack advertisement from a non-mutating snapshot
  instead of acquire_fresh, so the unlocked advertisement cannot delete or
  swap the live repo directory under a concurrent guarded write (the stale-ETag
  success ordering). acquire_fresh loses its only production caller and is
  removed.
- P1: read-gate, rate-limit, bound, and cancellation-clean the close_issue
  pre-lock snapshot, so a signed non-owner cannot drive unbounded Tigris
  downloads and blocking extractions, and an abandoned extraction no longer
  leaks its temp dir.
- P1: classify raw 409/412 responses as a lost conditional write via
  SdkError::raw_response() in both upload and the publish supersede-retry, so
  an unparsable error body cannot acknowledge a write that was never published.
- P2: wrap the cold-cache under-lock download failure in RepoUnavailable so it
  sheds as a retryable 503 like the HEAD arm, not a permanent 500.
- P2: surface a refused create-only fork upload as PreconditionLost and refuse
  the fork, so an orphan archive cannot shadow a fork's DB record.

Refs #279

* fix(node): record why the close-issue read gate cannot carry a synced row

The gate added for the pre-lock snapshot reads a repo row's own visibility
rules and public flag. A row synced from a peer is stored public and carries
none of the owner's rules, so for that class the gate can only return allow.

Refusing instead would deny the repo's real owner and the issue's real author
on any node whose only copy is a synced one, and it would not buy the
protection it appears to, because a synced row's recorded owner comes from the
peer that sent it. Every other read gate in the API reaches the same verdict
for such a row, so this is left as is and stated at the call site rather than
special-cased here.

The test that covered the gate seeds a locally created repo and cannot observe
this. Adds one that pins the property directly: the gate allows an arbitrary
caller on a synced row, and the handler refuses that caller anyway, because the
owner-or-author check is what decides this route.

* fix(node): address jatmn review on merged advisory-lock head

Align .env.example db pool with validate floor, move snapshot cleanup into
the blocking extract task, remove fork clones on PreconditionLost, restore
upload_site_reached test plumbing, and use owner DIDs in receive-pack tests
after #330 owner-push default.

* fix(node): cap close_issue snapshot work on the read pool

Take a git_read_semaphore permit before the non-owner author snapshot so
parallel close attempts cannot each drive a full archive extraction while still
under the hourly rate bucket. Format owner-DID test helpers.

* fix(node): mark close_issue mirror visibility gate for push hook

Annotate list_visibility_rules with mirror-rows-handled so the pre-push
surface detector accepts the synced-row read gate.

* fix: unblock pre-push after main merge

Read CappedBody.text in gl whoami (#381 shape), restore legacy CID sweep
wiring dropped in the merge, route rate-limiter cleanup through AppState,
and allow build_lock_pool for test-only callers.

* fix(node): carry transient acquire failures through every write handler

Extend acquire_write_app_error so RepoBusy and RepoUnavailable map to
fixed-body 503s on issue and pull paths, matching receive-pack. Fix the
flaky u2 drain exhaustion test via a drain_faults seam and add handler
regressions for contention and storage unavailability on create_issue.

* fix(node): drop unused logcap helper after drain_faults seam

* fix(node): route fork disk paths through validated_repo_disk_path

CodeQL flagged remove_dir_all on a user-derived path in fork_repo. Use the
same three-layer barrier as RepoStore::local_path and reject names the
validator refuses before clone.

* fix(node): harden release-upload outcomes and replication tail gating

Treat timed-out or failed archive uploads as non-durable in into_result,
gate pinata/gossip on confirmed publish durability while keeping inv22 spawn
order, split lock-pool vs main DB pool validation, map LockPoolBusy through
From<anyhow::Error>, and attach Retry-After on repo write contention errors.

* fix(node): close jatmn round on publish authority and durability gating

Revoke under-lock refresh swaps when the advisory lock ends, fail closed on
unrecorded release outcomes before Pinata/gossip, compensate fork archives when
create_repo fails, recompute acquire backoff from the live deadline, and route
publish swaps through validated_repo_disk_path for CodeQL.

* fix(node): harden issue close/create and fork publish paths on #285

Move close_issue rate limiting after the read gate so denied readers see 404
not 429, roll back local issue refs when publish refuses, re-check swap
authority under publish_lock, propagate fork upload failures before DB insert,
and update lock-pool config help text.

* fix(node): close write-attempt ownership gaps on advisory-lock release

Make publish swaps claim-exclusive via CAS, invalidate the local read cache
on definite publish refusals, harden fork create compensation and clone
cleanup, and keep snapshot temp dirs owned through the async handoff.

* fix(node): gate replication tail on publish durability and split close_issue limits

Wait for a confirmed release outcome before post_receive_replication_tail runs
any walks or coalescing work. Give close_issue its own per-IP rate bucket so
it cannot drain receive-pack quota. Add regression tests for both paths.

* fix(node): add ValidatedRepoDiskPath for CodeQL and reconcile tail durability

Introduce a validated-path newtype with the same inline join and component
walk main uses, so path-injection sinks only accept sanitised repo paths.
Reconcile the publish-durability gate with the disconnect-safe tail spawn via
PublishDurabilitySlot and explicit UploadUnknowable handling.

* fix(node): close three jatmn findings on advisory-lock PR

Retry fork create confirmation before compensating or returning, schedule
background recovery when the lookup stays unavailable, install publish
durability outcomes before the recorded flag, and size the lock pool for
non-push mutations (default 40).

* fix(node): verify adversarial findings on advisory-lock PR

Fail closed when publish durability never records, bound pg_advisory_unlock
with the same transfer budget as upload, and cap close_issue pre-lock snapshots
at git_acquire_timeout_secs instead of the write-lock transfer bound.

* fix(node): compensate under the write lock and harden tail durability handoff

Run create_issue rollback through release_compensating so issue refs are
deleted only while the advisory lock is still held and only on definite
publish refusals, not UploadUnknowable. Replace the tail durability slot's
async try_lock drop with a std mutex so cancellation during polling cannot
skip installing UploadUnknowable.

* fix(node): refuse stale under-lock refresh and gate tail durability on release

Under-lock GET failures now always shed as RepoUnavailable, even when a local
copy exists, so a transient download error cannot publish over a newer stored
generation. PublishDurabilitySlot only synthesizes UploadUnknowable after
release starts, and receive-pack marks that boundary before spawning the tail.

Adds regression tests for both paths; inv22 F4 gate accepts the release wrapper.

* style(node): rustfmt advisory-lock gap-closure commits

* fix(node): satisfy clippy on stale-local download test path join

* feat(node): add a publish attempt/stage boundary

The four lifecycle findings on this branch share one root cause: a resource
was tracked by a coarse state ("release started", "the path exists", "a row
with this name exists") when the safety question was about the IDENTITY and
the PUBLICATION STAGE of one specific write attempt.

This is the vocabulary that makes those questions answerable:

- `PublishAttemptId` — minted before the request is built, carried with the
  bytes as object user metadata, read back off the store to decide whether
  what is published is THIS attempt's work. That turns "did my request
  succeed", which a lost response makes undecidable, into "are the published
  bytes mine", which the store can answer.
- `PublishStage` / `PublishStageCell` — how far one attempt got, observable
  from OUTSIDE the future doing the work. A cancelled handler never returns
  an outcome, so the stage it had reached is the only thing that can classify
  it.
- `UploadError` split by what a failure ENTITLES A CALLER TO DO rather than
  by which HTTP status came back. Destructive compensation is licensed only
  by `proves_not_published()`.

Deliberately free of any object-storage type. #79 deletes `git/tigris.rs` and
replaces it with a BlobStore layer; this module is what survives that swap,
with each backend supplying only its own error classifier.

* feat(node): stamp uploads with an attempt id and preserve dispatch ambiguity

`upload` now reports its progress into a `PublishStageCell` and stamps every
PUT with an attempt id in object user metadata, so both of the questions a
cancelled or unanswered publish raises become answerable:

- `PreparingArchive` is marked before the blocking compression and
  `PutDispatched` immediately before `send()`. The conditional PUT is not
  constructed until compression returns, so a caller abandoned in that window
  definitely never attempted publication — a fact nothing could previously
  observe, because the only report was the return value of a future that no
  longer existed.
- `attempt_landed()` HEADs the key and compares the stored attempt id, which
  is what lets a client whose response was lost recover its own committed
  write instead of guessing.

`UploadError::Other` is replaced by `NotPublished` (proven never committed)
and `Ambiguous` (may have been dispatched and may have committed). The single
backend-aware classifier is `classify_put_failure`: a 4xx is an answer the
server gave before storing anything and proves non-publication; a 5xx does
not, and neither does a timeout, a dispatch failure, or a response the SDK
could not read. `SdkError` is `#[non_exhaustive]`, so the fallback arm is the
cautious one.

`delete` is replaced by `delete_if_attempt_matches`, which reads the attempt
off the object and fences the DELETE with `If-Match` on the generation it
came from. A second name lookup before an unconditional delete would only
narrow the window in which a successor's object can be erased; the
conditional delete closes it.

* fix(node): key durability, cache validity and fork cleanup on the attempt

Closes the four lifecycle findings by consuming the attempt/stage boundary at
the sites that were deciding on coarse state.

P1 — cancellation before PUT dispatch is not publish durability.
`PublishDurabilitySlot` is armed on the guard's publish STAGE rather than on
a "release started" flag, and its `Drop` classifies an abandoned handler by
how far the attempt actually got: a cancellation during compression records a
definite refusal, a cancellation after dispatch records unknowable, and a
cancellation after the store acknowledged records `Released` (which the flag
could never say). `publish_durability_confirmed` now requires `Released`, so
the detached tail no longer does IPFS/Pinata/P2P/GraphQL/Arweave/peer work
for refs that exist only in a rejected local write. A guard with no storage
backend seeds `NoBackend`, keeping Tigris-less deployments' tails running.

P1 — an unknowable generation is quarantined, not served as ordinary cache.
`release` writes a sidecar marker naming the unresolved attempt beside the
live tree (never inside it: it would be tarred into the next archive), and
`acquire`'s existing-path fast path reconciles it before serving. Only the
store confirming it holds that attempt lifts the quarantine; anything else is
a retryable `RepoUnavailable`. The tree is deliberately NOT deleted — the PUT
may have landed and this can be the only local copy. A confirmed publish, an
under-lock refresh, and cache invalidation each clear the marker, so the
quarantine is a bounded refusal rather than a standing outage.

The release-side timeout arm also stops treating every stall alike: a bound
that expires at `PreparingArchive` is a definite non-publication, and a bound
that expires after dispatch spends one short, separately bounded HEAD trying
to reconcile the attempt before falling back to unknowable.

P1 — fork confirmation and cleanup bind to the creating attempt. The DB row
id is minted up front and used as the attempt id, so the object's metadata,
the clone's sidecar stamp and the row all name one attempt.
`confirm_fork_repo_row` looks the row up BY ID and reports Ours / Foreign /
Absent, so a concurrent create, mirror registration or retry can no longer be
returned as this request's own commit. Every destructive step —
`compensate_fork_archive`, its background retries, and `ForkCloneGuard::drop`
— is conditional on the resource still belonging to this attempt, so recovery
that observed `None` before a successor committed can no longer erase that
successor's archive or directory afterwards.

P2 — response-loss ambiguity is preserved rather than compensated. Guarded
writes map an ambiguous publish to `UploadUnknowable` + quarantine instead of
`UploadFailed` + cache invalidation + the caller's undo, so `create_issue` no
longer deletes an issue ref whose archive is durable. Fork creation
reconciles by attempt id: a create-only PUT that committed and lost its
response is RECOVERED and the row inserted, and an unresolved one keeps its
only local clone and refuses retryably. The fork name is no longer fenced
behind the attempt's own orphan.

Tests, each RED-checked by reverting its guard:

- slot_drop_during_compression_records_a_definite_non_publication
- slot_drop_after_dispatch_records_unknowable
- slot_drop_after_the_store_acknowledged_records_released
- slot_drop_with_no_storage_backend_records_released
- publish_durability_confirmed_accepts_only_released
- publish_durability_confirmed_refuses_quickly_after_unrecorded_slot_drop
- receive_pack_cancelled_during_compression_publishes_nothing_and_runs_no_tail
  (+ receive_pack_that_completes_its_publish_still_runs_the_tail as control)
- a_bound_that_expires_before_dispatch_is_a_definite_failure
- an_unresolved_publish_quarantines_the_tree_and_a_later_read_refuses
- a_quarantined_tree_is_served_once_the_store_confirms_the_attempt
- the_next_write_clears_an_inherited_quarantine
- a_confirmed_publish_leaves_the_tree_readable (control)
- a_guarded_write_whose_response_is_lost_is_not_compensated
- a_put_that_commits_and_loses_its_response_is_ambiguous_and_reconcilable
- a_closed_response_with_no_http_status_is_ambiguous
- upload_classifies_404_as_a_definite_non_publication
- upload_classifies_500_as_ambiguous_not_definite_failure
- a_generation_that_moves_between_the_head_and_the_delete_is_not_deleted
- an_attempts_own_object_is_deleted_and_a_foreign_one_is_not
- fork_confirmation_never_claims_a_concurrent_attempts_row
- fork_confirmation_recognizes_this_attempts_own_committed_row
- fork_confirmation_reports_absent_when_nothing_owns_the_name
- fork_clone_guard_leaves_a_successors_mirror_alone
- fork_recovery_resuming_after_a_successor_deletes_neither_object_nor_path
- fork_compensation_removes_what_this_attempt_still_owns
- fork_publish_that_loses_its_response_stays_recoverable
- a_fork_whose_publish_lost_its_response_is_recovered_not_fenced
- a_fork_whose_publish_is_unresolved_keeps_its_clone_and_refuses_retryably
- an_ordinary_fork_publishes_and_commits (control)
- mock_round_trips_the_attempt_metadata_a_put_stamped

The S3 mock gains attempt metadata, conditional DELETE, a commit-then-lose-
the-response mode, a delivered-but-not-committed mode, and a per-key object
store (fork creation touches the source and fork keys in one request, and a
single slot would have let an assertion about one silently read the other).
The compression seam is a condvar gate so no test holds a guard across an
await.

* fix(node): settle the cache contract on the abandoned path and the attempt

The publication boundary this branch introduced was applied at call sites
that each had to be remembered, and five were not. Close the eight gaps
that survived, grouped by what actually failed rather than by symptom.

The marker's lifecycle was answered by the wrong event in three places.
The swap that replaces a live tree now owns the clear, so a cache-miss
download no longer leaves a stale marker that fences the archive it just
installed, and the under-lock refresh no longer lifts a quarantine on a
HEAD that downloaded nothing. init clears a predecessor's marker.

A cancelled handler never reached release, so it applied the tail
contract and not the cache contract. RepoWriteGuard::drop now settles the
tree it abandoned, keyed on how far the publish actually got, and a guard
that handed out its path for writing marks the tree whatever stage it
reached. A marker naming no attempt is a definite non-publication rather
than a permanent refusal: it invalidates and the next read serves the
stored generation, so an abandoned first push cannot wedge a repo at 503.
Only an unreadable sidecar still fails shut.

Both of the store's live-path hand-outs now go through one confirming
helper, so read_snapshot can no longer return an unconfirmed tree as an
ordinary snapshot. The gate binds ahead of the lazy-migration spawn,
because a quarantined tree backfilled to object storage by an IfAbsent
PUT would publish exactly the refs the marker exists to withhold. The
marker no longer rests on a single fs::write; an unwritable sidecar falls
back to an in-memory record that reads consult.

Fork cleanup claims ownership by renaming the stamp before it touches the
tree, so a successor that claims the same path cannot lose its directory
to a predecessor's check-then-unlink, and the directory-absent path no
longer unlinks a stamp it does not own. A create-only precondition loss
whose reconciliation HEAD fails is refused retryably instead of reported
as a permanent name conflict, and its clone is released so the retry can
re-clone rather than finding the destination occupied.

The pin sweep skips a tree whose publish is unresolved, so provider CIDs
are not rewritten from refs that may never become durable. That is the
sidecar marker, distinct from the operator-level quarantined row flag.

26 tests, each written and observed failing before its fix. Suite 1211
passed, 0 failed, 1 ignored; fmt, clippy -D warnings and --locked clean.

* fix(node): settle the abandoned tree at the disconnect, not at reaper exit

The settlement that marks an abandoned write ran from RepoWriteGuard's
Drop, and on a client disconnect that guard is held by the Arc clone
riding the admission guard into the detached reaper, so it ran only once
the process group had been reaped. Reads take no advisory lock and
consult only the sidecar, so for the width of that window a concurrent
fetch was served the abandoned refs off the live tree with no marker
beside it. Confirmed by execution before the fix: no marker on disk,
acquire returning the live path, and the abandoned ref still on the tree
it served.

Only the lock release has to wait for the reaper; the settlement does
not. TreeSettlement is a token carrying the path, the publish stage and
the hand-out flag, holding the exhaustive stage match that used to live
in Drop. The handler takes it out of the guard before the guard is
shared with the reaper, so on a disconnect the handler future drops
first and the marker lands at the disconnect instant while the reaper
still holds the lock. A successful release disarms it. Drop still
settles a guard nobody took the token from, so the arms have one home.

The regression test reads inside the window and asserts the abandoned
refs are not served. It samples pg_locks from an independent session
first, so a run where the reaper had already finished reports itself
inconclusive rather than passing for the wrong reason.

Suite 1212 passed, 0 failed, 1 ignored. The five settlement mutations
are load-bearing, including one that reverts this split and reddens the
window test.

* fix(node): let exactly one classifier settle an abandoned tree

Moving settlement into a token the handler holds left two classifiers
able to run for one write. release() classifies the tree and only then
awaits the unlock, while the handler disarms the token after that await
returns, so a cancel in the unlock gap dropped a still-armed token and
settled a second time. After a definite refusal that is a quarantine
marker written beside the tree release had just deleted, which sends
every later read down the refuse-and-reconcile path for a repo whose
state was already resolved. Observed before the fix: the tree gone, the
marker there.

The arm is now an AtomicBool shared between the guard and its token.
release stores false at the classification point, above the unlock
await, and settle takes the arm with compare_exchange, so whichever
classifier gets there first is the only one that acts.

tree_settled stays, and now has a test that says why: after the handler
takes the settlement the arm is still true, so tree_settled is the only
thing stopping the guard's own Drop, which on a disconnect runs in the
detached reaper, from settling again at reaper exit.

The reap-window test was passing for weaker reasons than it claimed. Its
fake git took SIGTERM and let the group die, so the reaper could free
the advisory lock before the probe read; it now traps TERM and re-parks
so only SIGKILL ends it. It asserts the marker exists at the disconnect
instant rather than only mentioning it, and the served-refs half no
longer passes when acquire fails for an unrelated reason.

Suite 1214 passed, 0 failed, 1 ignored. Nineteen mutations load-bearing,
including one that reverts the release-side disarm and one that reverts
the settlement split.

* test(node): use the test's own pool now that Db::pool is gone

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-04 21:38:41 +00:00
beardthelion 19a7ce2bbd fix(node): drop withheld-subtree trees from the replication pin set (#382)
* fix(node): drop withheld-subtree trees from the replication pin set

A tree reachable only under a denied path survived both pin filters because
trees were never in the blob universe, so public IPFS still learned child
filenames and blob oids. Filter trees the same way as blobs on the delta
withheld set and the full-scan allow-list.

Closes #172.

* chore: re-trigger code quality check
2026-11-04 20:54:43 +00:00
beardthelion 189aad96e5 fix(node): require Content-Digest on a signed request (#306)
* fix(node): require Content-Digest on a signed request

require_signature rebuilds the RFC 9421 signing string from the request
itself, and it read the Content-Digest header with a .unwrap_or("")
fallback. When the header was absent, the covered content-digest component
came out as the empty string on both the signing side and the verifying
side, so the Ed25519 check passed over a string that committed to nothing
about the body. The comparison that would have caught it was guarded on the
presence of that same absent header, so it was skipped. The two defects
compose: a signed request could carry any body at all, and the handler
behind the middleware saw it as authenticated.

The header is now required. An absent Content-Digest is a 400 with
missing_content_digest, and the digest-versus-body comparison is
unconditional, since presence is established before it runs. There is no
longer a path through the middleware on which the body goes uncompared.

Refusing is safe for every client that already works. sign_request computes
the digest with no branch and always returns it in SignedHeaders, and all
eight production call sites attach it as a Content-Digest header: the three
in gl's HTTP client, the fetch and push requests in git-remote-gitlawb, the
node's peer announce, the sync push in sync.rs, and the sync notify in
api/repos.rs. So no conforming client regresses. What the refusal removes is
the case where a caller signed an empty digest and sent a body no signature
covered.

HttpSignature::parse now also refuses a covered component that appears more
than once, per RFC 9421 section 2.1. A repeated identifier says nothing the
single one did not, and it lengthens the signing string a verifier has to
build from a list the caller chooses.

* fix(core): linear duplicate check and reject component parameters

Use a HashSet for covered-component duplicate detection so hostile
Signature-Input headers cannot force quadratic work before rejection.
Reject RFC 9421 component parameters during parse since only bare names
are supported end to end.

* chore: re-trigger code quality check
2026-11-04 20:33:16 +00:00
beardthelion 6233bd9d85 fix(node): validate git branch refs to close option injection (#379)
* fix(node): validate git branch refs to close option injection

PR branch refs and a repo's default_branch were stored from request bodies
with no ref validation, then interpolated into single git argv elements:
git diff {target}...{source} (branch_diff / branch_diff_names), and
git worktree add ... {target} / git merge {source} (merge_branch). A value
beginning with '-', e.g. --output=/tmp/x, is parsed by git as an option
rather than a revision, so it becomes an arbitrary file write. get_pr_diff
takes an optional identity, so on a public repo the trigger is unauthenticated;
planting the PR needs only read access, and the write happens at the withhold
check before the visibility gate.

Defense is applied at two layers:
- Storage boundaries: create_pr validates source_branch and the resolved
  target_branch; create_repo validates default_branch (which becomes a PR's
  target when the PR omits one). These fail fast with 400 and keep junk out
  of the DB.
- The sink: branch_diff, branch_diff_names, and merge_branch reject an
  option-shaped ref before building the git argv, so the property holds for
  every caller and every row, including legacy rows and any future writer
  that skips the boundary check.

The shared validator is crate::git::store::validate_git_ref (git
check-ref-format rules, leading-dash rejection as the core), re-exported as
crate::api::validate_git_ref for the boundary handlers. No -- delimiter is
used: the arguments are revisions, and -- there reinterprets them as
pathspecs.

Both boundary guards and the sink guard are mutation-proven load-bearing.
resolve_head is unaffected (it prefixes refs/heads/); fork_repo takes no
branch from the request.

* fix(node): delegate branch ref validation to git check-ref-format

Rebase onto current main and tighten validate_git_ref to call
git check-ref-format --branch so symbolic names like HEAD and
trailing-dot components are rejected at storage boundaries and
git sinks. Add boundary and legacy-row tests for HEAD, feature.,
and feature/x.

* fix(node): reject fully qualified refs and close review test gaps

Reject refs/ prefixes after check-ref-format so tags and qualified
head names cannot be stored as PR branch fields. Add sink tests for
option-shaped merge source, HEAD target, and poisoned default_branch
resolution; assert get_pr_diff fails on poisoned diff rows.

* fix(node): allow-unbounded-git for check-ref-format validation

Stateless ref-name validation holds no repo concurrency permit.

* style: cargo fmt

* fix(node): reject revision shorthands and split ref-validation errors

git check-ref-format accepts @ and pseudoref names that git later treats as
symbolic revisions, so merge could succeed without merging the source branch.
Reject those explicitly at the shared validator and add sink/boundary tests.

Spawn failures from check-ref-format now surface as git_error (500) instead of
bad_request (400) at create_pr/create_repo boundaries.

* test(node): close ce-review gaps on git ref validation

Add sink tests for @{-1} and pseudoref poisoned rows at diff/merge,
HTTP tests that spawn failure returns git_error (500) not bad_request,
and expand API boundary cases for remaining pseudorefs and qualified refs.

Introduce GITLAWB_TEST_VALIDATE_GIT_BIN test seam with a mutex so parallel
sqlx tests do not race on the override.

* fix(node): prefix sink git argv with refs/heads and close shorthand gap

Pass stored branch short names to diff/merge/worktree as refs/heads/{name}
so revision shorthands cannot retarget another ref namespace at the sink.
Reject heads/, tags/, and remotes/ prefixes at storage boundaries, extend the
pseudoref denylist, and route boundary validation through AppState::git_bin
instead of a process-global test env override that raced parallel tests.

* fix(node): attach merge worktree to the target branch

git worktree add with refs/heads/{name} checks out detached HEAD, so a
successful merge did not advance refs/heads/{target}. Use the branch name
for the worktree and keep refs/heads/ qualification on merge revisions.
Add an end-to-end regression for diverged branches.

* chore: cargo fmt on merge ref-advance test

* fix(node): bind merge worktree to the exact local target branch

validate_git_ref proves branch-name grammar only; git's revision DWIM can
resolve the same short name in another namespace (refs/tags/{name}), so a
PR targeting a tag-shadowed name checked out a detached HEAD, merged onto
it, discarded the commit at cleanup, and the unchecked plain rev-parse
echoed refs/heads/{target} back as a bogus merge SHA — marking the PR
merged and firing pull_request.merged with no branch advanced.

Close the resolution class instead of denylisting names: (1) require the
exact refs/heads/{target} via show-ref --verify before creating the
worktree, (2) verify the worktree's symbolic HEAD is attached to exactly
that ref before merging, (3) accept the merge only if rev-parse --verify
{target}^{commit} exits zero. Add end-to-end regressions for a same-named
tag with no local branch (merge fails, PR stays open, no merged event) and
for a tag/branch coexistence (the local branch advances, the tag stays).

* fix(node): recover a leftover merge worktree instead of wedging the endpoint

merge_branch cleaned up a leftover _merge_worktree only when the directory
still existed. The state git worktree add actually refuses is the opposite: a
registration under $GIT_DIR/worktrees whose directory is gone. It fails with
"missing but already registered" (exit 128), and once a repo is in that state
every later merge on it fails, permanently.

The state is self-inflicted. remove_worktree runs git worktree remove and then
removes the directory unconditionally, so one failed removal leaves the
registration behind.

Two changes. Run the cleanup unconditionally at entry, which also makes the
bail-without-cleanup paths self-healing on the next merge. And pass --force
twice: a single --force refuses a LOCKED worktree ("cannot remove a locked
working tree"), and git worktree prune skips locked registrations too, so with
one flag a locked leftover survives the whole cleanup and git worktree add
then fails with "missing but locked worktree".

Enumerated the leftover states that can reach this code (registered or not, x
directory present / gone / locked / corrupt admin dir, x gc.worktreePruneExpire
default and never). remove -f -f plus the directory removal clears all of them,
and adding a separate git worktree prune changes no outcome in any of them, so
there is no prune call here.

Four tests, one per leftover shape that reaches this code. Against
merge_branch: the plain stale registration, the locked variant, and an
unregistered leftover DIRECTORY (git worktree remove refuses that one with "is
not a working tree", so the directory removal is what clears it). Plus an
end-to-end pass through the merge endpoint on the locked state, asserting the
PR row reaches "merged", the reported merge_sha IS the new refs/heads/{target},
and the merge leaves no worktree behind for the next one. The e2e fixture
asserts git really refuses the state before asserting recovery, so a future git
cannot make it vacuous.

* chore: re-trigger code quality check
2026-11-04 17:55:12 +00:00
beardthelionandKevin Codex 76ca04f355 fix(node): close three host-class gaps in the public-URL gate (#346)
* fix(node): close three host-class gaps in the public-URL gate (#339)

is_public_http_url is the shared gate for every operator-supplied outbound
URL: peer announce, webhook creation, and the boot-time peer prune. Three
host forms got past it that the checks around them plainly meant to exclude.

A name with no dot never matched the `.local` / `.internal` suffix rules, so
`http://internal/` and `http://wpad/` were accepted and left to the resolver
to complete from its search domain. The dotless rule sits on the branch where
the host did not parse as an IP literal, which keeps bracketed IPv6 (also
dotless) on the accepting path; the existing 6to4 and NAT64 accept cases pin
that.

fec0::/10 fell through the IPv6 arm because the link-local mask does not
cover it: 0xfec0 & 0xffc0 is 0xfec0, not 0xfe80. RFC 3879 deprecated the
range, which argues for rejecting it rather than ignoring it.

Trailing root dots were stripped once, so `localhost..` reduced to
`localhost.` and matched neither the equality nor the suffix check. Stripping
to fixation closes that.

Each of the three is covered by a test that reddens when its own production
line is reverted, and the dotted forms stay as negative controls so the
suffix checks remain proven.

* fix(gl): consume CappedBody text in whoami error path after main merge

* chore: re-trigger code quality check

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-04 17:20:36 +00:00
beardthelionandKevin Codex c301a68161 fix(node): persist the libp2p identity instead of deriving it from the node DID (#324)
* feat(node): add a persistent libp2p identity key file

Add p2p_key_path (--p2p-key-path / GITLAWB_P2P_KEY, default
~/.gitlawb/p2p.key) with a resolver mirroring resolved_key_path, and
load_or_create_p2p_keypair, which generates an Ed25519 keypair on first
start, persists it 0600, and loads it thereafter. Mirrors the existing
load_or_create_keypair idiom for the node identity PEM.

A corrupt or unreadable key file is a hard error naming the path rather
than a silent regeneration, so a disk problem cannot quietly rotate the
node's network identity.

Not yet wired into p2p::start; that follows.

* fix(node): load the libp2p identity from the persistent key file

p2p::start now takes the Ed25519 keypair loaded by
load_or_create_p2p_keypair instead of computing one from the node DID,
so a node's network identity is generated once from the OS RNG and kept
on disk rather than recomputed from a public value on every start.

The node DID parameter is gone from start; the call site loads the key
first and continues without p2p if the key file cannot be read, matching
how a swarm-start failure is already handled.

The gossipsub message_id_fn is untouched and keeps its own hasher.

* fix(node): create the p2p key with 0600 at creation and reject a loose one

Open the key file with create_new and the mode set at creation, then
fsync, instead of writing it and narrowing the mode afterwards. The
secret is never on disk under a wider mode, an interrupted start cannot
leave it readable, and the exclusive open also refuses a pre-existing
entry at the path and makes a concurrent start take the key that landed
rather than clobber it.

Refuse to load a key file whose mode grants group or other access, and
name the observed mode so the operator can fix it. Report an empty key
file as empty rather than surfacing a protobuf decode error that blames
a missing rsa feature.

Pin GITLAWB_P2P_KEY onto the mounted volume in the Docker and fly
configs and document it, so the key does not depend on home-directory
resolution to land on persistent storage.

* fix(node): publish the p2p key atomically and pin its directory

Write the key to a scratch file in the same directory and hard-link it
onto the final path. The bytes are durable before any name points at
them, so a crash cannot leave a partial key that fails to load on the
next start and takes the node off the network until someone reads the
logs. A concurrent reader can no longer observe a half-written file
either, since the final name appears complete or not at all.

hard_link rather than rename: rename replaces its destination silently,
so refusing to clobber an existing key would depend on a check followed
by a separate rename, and a concurrent start can land in that gap.
hard_link is atomic and refuses an occupied path, including a symlink,
which it does not follow.

Create the key directory 0700 and tighten it when an existing one grants
group or other access. A 0600 key under a writable directory can still be
replaced or unlinked. Tightening rather than refusing to start, because
existing installs already have 0755 there and refusing would take p2p
down on all of them through a path that only warns.

Formatting on the branch is swept up here; it was already failing
cargo fmt --check before this change.

* style(node): comma instead of a dash in the key-load warning

House style avoids em dashes in text we write. The swarm-failure warning
beside it predates this branch and is left alone.

* fix(node): require the p2p key path to name a directory

A bare filename in GITLAWB_P2P_KEY put the key in whatever directory the
process started from, and the directory guard was skipped entirely on that
path: Path::parent returns Some("") for a bare filename, which the caller
filtered out before ever reaching ensure_key_dir. The key file was created
0600 inside a directory that kept whatever mode it already had.

Config::validate now rejects a p2p key path that names no directory, so the
node says so at boot instead of starting with a key it cannot protect. That
placement is the point: an error raised in the p2p start path is logged and
stepped over, leaving the node running without p2p and reporting healthy.

The check is lexical on the tilde-resolved path. canonicalize would fail on a
parent that does not exist yet, which is the shipped ~/.gitlawb default and
every container's first boot, and comparing against the working directory
would reject /data/p2p.key under the image's WORKDIR, an absolute directory
the operator did name.

Three sites answered the parent question differently, which is how the gap
arose: one filtered the empty case out, one already normalized it, and one
opened "" and silently skipped its fsync. They now share key_parent, and
Config::validate calls it rather than adding a fourth answer.

load_or_create_p2p_keypair also refuses a path naming no directory. That is a
backstop behind the config gate, not the gate, so a later caller that skips
validation cannot quietly restore the old behaviour.

* test(node): run the key-permission probe in its own process

The probe zeroes the umask so the assertion means something: under a
restrictive ambient umask the bits are masked to 0600 regardless of whether
the code pins the mode, and the check passes either way.

Zeroing it in the shared test process is the problem. umask is process-global
and cargo runs these tests on threads, so any test creating a file in that
window inherits 000. Measured before this change: an unrelated concurrent
test's file was created 0666.

The probe now runs in a child process, where the zeroed umask cannot reach a
sibling and dies with the child. The parent is an ordinary test that runs
concurrently with everything else. Double-gated with #[ignore] plus an env
check so a bare --ignored sweep does not zero the umask in the shared process
after all.

The parent asserts the child ran exactly one test and that it passed, not
just that it exited 0. A libtest filter matching nothing runs zero tests and
still exits 0, so without that assertion a renamed fixture would read as a
green permission check while asserting nothing. Verified by pointing the
filter at a name that does not exist and watching the parent fail.

* docs(node): scope the p2p key permission claim and note the rotation

The old wording said the key file is "created with owner-only permissions"
without qualification, which is only true on Unix: every permission path in
p2p/mod.rs is cfg(unix), so on other platforms the file inherits whatever the
directory gives it and nothing is enforced. Say what is actually enforced and
where.

Also document what operators now have to do rather than leaving them to
discover it:

- GITLAWB_P2P_KEY must name a directory, since a bare filename is refused at
  startup.
- The PeerId rotates once on the first start after upgrading, so a
  GITLAWB_P2P_BOOTSTRAP multiaddr pinning a peer's old id with a /p2p/<PeerId>
  suffix needs updating or dropping. Suffix-less addresses and the HTTP seed
  list are unaffected.
- If the node reports tightening a loose key directory, the key that was in it
  should be treated as possibly exposed and deleted so a fresh one is
  generated.

* fix(review): close a `..` escape from the p2p key-path check

Two reviewers found the same hole independently: the check rejected a path
naming no directory, but a relative parent that walks back out through `..`
named one and still landed in the working directory. `a/../p2p.key` and
`./keys/../p2p.key` resolve to the cwd itself and `../p2p.key` resolves above
it, so all three put the key exactly where the check exists to keep it out of,
and had ensure_key_dir chmod that directory to 0700 on the way. Verified by
running the paths through the predicate and printing where each parent lands.

The rule is now that a relative key path must name a directory and must not
walk back out: at least one Normal component, no ParentDir. `..` inside an
absolute path stays accepted, since it cannot depend on where the process
started. The predicate moves into names_no_usable_directory next to key_parent,
and the config gate and the load_or_create_p2p_keypair backstop both call it, so
they cannot drift apart.

Also fixes two smaller gaps found in the same pass:

- The permission fixture could report "1 passed" while asserting nothing. Its
  env gate returns early, and an early return is a passing test, so a renamed
  variable would look green. It now prints a sentinel after its assertions and
  the parent requires it. Confirmed by pointing the child at a different
  variable and watching the parent fail.
- A GITLAWB_P2P_KEY starting with `~/` is refused when no home directory
  resolves, instead of creating a literal `~` directory relative to wherever
  the node happened to start.

The backstop had no test, so it has one now, along with a both-directions test
for the predicate. That test cleans up after itself: with the guard removed it
really does write a key next to the source, which broke a later run once.

* fix(review): reject `..` in an absolute p2p key path too

The previous commit closed this for relative paths and exempted absolute ones,
reasoning that an absolute path cannot depend on the working directory. That is
true and it is not the hazard. `key_parent` hands `ensure_key_dir` the lexical
parent, so `/data/keys/../p2p.key` chmods `/data` rather than the `keys`
directory the path appears to name, and `/data/../p2p.key` run as root would
try to tighten `/` to 0700. The exemption also had a test asserting the first of
those was fine, so the gap was written down as intended behaviour.

`..` is now rejected wherever it appears. An absolute path's root counts as
naming a directory, so `/p2p.key` still validates and `/data/keys/p2p.key` is
unaffected.

Found by a second-model review pass after the in-process reviewers had cleared
the relative half.

* fix(node): scrub the serialized p2p private key from memory

Two buffers held the private key in its protobuf form and dropped without
scrubbing: the encoding produced when a new identity is generated, and the file
contents read back on every subsequent start. Both are now Zeroizing, matching
what gitlawb-core already does for its own key material.

Scope worth being honest about: this scrubs our copies of the serialized form,
not the libp2p Keypair itself, which owns the secret for the process lifetime
and exposes no way to zeroize it. The gain is that the encoded bytes do not
outlive the write and the read.

zeroize was already in the tree through gitlawb-core, so this promotes it to a
direct dependency of gitlawb-node and adds no packages; the lockfile change is
the one line recording that.

* fix(node): validate the p2p key path before securing its parent

Reject filesystem-root parents, directory-valued paths, and symlinks before
any directory is created or chmodded. Create missing ancestors at the ambient
mode and pin only the nominated key directory to 0700. Refuse symlink reads
with O_NOFOLLOW, propagate directory durability sync failures, and document
the loose-key rejection path for operators.

* fix(node): satisfy clippy single-element-loop on p2p path test

* fix(node): tighten the p2p key storage contract before mutation

Anchor parent-directory checks on symlink_metadata, reject ~/ escapes and
non-regular existing keys with a bounded read, treat directory AlreadyExists
as a first-boot race, and skip key-path validation when P2P is disabled.

* fix(node): refuse foreign-owned p2p key paths and cap reads on all platforms

Check directory and key ownership before chmod or load, walk ancestors for
foreign control, bound non-Unix reads to MAX_P2P_KEY_BYTES, and gate unix-only
fixture tests without panicking on other targets.

* fix(node): hold the p2p key directory open across its trust checks

Resolve the key path once, then do every check and mutation through an
O_DIRECTORY|O_NOFOLLOW handle on the parent (openat/fstat/fchmod/linkat),
so nothing can be swapped between the ownership check, the tighten, and
the key open. Key reads go through one bounded, non-blocking loader on
every platform; new keys are generated in an unlinked scratch file and
published atomically, with concurrent first boots converging on the
winner's identity. The storage contract is indexed by two matrix tests:
filesystem shapes in p2p (each refusal proven side-effect free against a
recursive snapshot) and path spellings plus the p2p-disabled guarantee
in config.

* fix(node): walk the p2p key ancestor chain descriptor-anchored

Replace the path-following metadata walk (foreign_ancestor_error) and the
post-walk create_dir_all gap with a single descriptor-anchored walk that
verifies and creates the chain together. Missing components are created at
0700 relative to the verified parent and re-verified; AlreadyExists is a race
the winner must pass. Non-unix builds keep create_dir_all.

* fix(node): refuse group-writable and non-directory p2p key ancestors

The ancestor walk now refuses any component with group or world write bits
unless sticky, and any intermediate component that is a symlink or another
object type. Tests drive the walk through real boots: 0770/0775 ancestors
refused before any key IO, 0755 and 1777-sticky ancestors accepted with stable
reload, and an intermediate symlink refused with its target untouched. Both
guards proven load-bearing by neuter revert-checks.

* fix(node): verify the cwd anchor for relative p2p key paths

A bare-relative key path is now walked from a verified working directory:
the cwd is opened as '.' with O_NOFOLLOW|O_DIRECTORY, fstat-verified for
ownership and write bits before any relative component is touched, and a
writable or foreign-owned cwd is refused before anything is created. The
refusal names the working directory path. Child-process fixtures prove both
directions without mutating the shared process cwd.

* test(node): stop rendering uids in p2p assert-message strings

CodeQL flags the uid-interpolating assert messages as cleartext logging to a
console sink. Every assert message in the p2p test module now avoids
interpolating uid values or the uid-bearing error text, while the substantive
assertions (the refusal names both uids, the ownership check fires before the
chmod) stay intact and are verified by the same assertions.

* test(node): add walk rows to the p2p key-storage contract matrix

The matrix now proves the ancestor-walk refusals table-driven: a
group-writable (0775) ancestor, a world-writable non-sticky (0777) ancestor,
and an intermediate symlink component are each refused with the no-mutation
snapshot discipline, while the existing boot rows still reload to a stable
PeerId. The fchmod-on-create path pins newly created ancestors to 0700
regardless of the ambient umask.

* docs(node): note the verified working directory for relative p2p key paths

README, .env.example, and the clap help for GITLAWB_P2P_KEY now state that a
relative key path is anchored at a verified working directory, and that an
unsafe cwd or ancestor is refused with p2p off while HTTP stays up. Also
normalizes two pre-existing em dashes in the README settings table.

* fix(node): pin and verify the mode of every key object this process creates

POSIX applies the process umask to every requested creation mode, so the
0700 passed to mkdirat and the 0600 passed to openat were requests rather
than results. Under a mask that strips owner bits the created directory
landed 0000 and the no-follow reopen failed EACCES before the repairing
fchmod was reached, so that half failed loudly. The scratch key was worse:
it was published unreadable, the boot that created it succeeded on its
already-open descriptor, and only the next boot lost p2p, behind a health
check that still reported healthy.

Every object this process creates now goes through one rule: create, pin
the mode on the object just created, reopen, and verify the achieved mode
by fstat. The directory pin is issued by name off the verified parent
before the reopen, because a directory that landed 0000 cannot be opened
at all, so a pin that waits for the reopen is unreachable in exactly the
case it exists for. A race winner is verified as-is and never chmodded,
which is the rule the ancestor walk already applied.

Pinned's field is private to the pin module and no constructor is
exported, so a descriptor that skipped the pin cannot be turned into a
key-directory handle or reach publication. That is what keeps the rule
from depending on review.

Two adjacent corrections the same defect exposed. The leaf mode predicate
was supersets-only: mode & 0o077 != 0 asks whether anything is granted
beyond the owner, so 0000, 0100 and 0400 all passed it while being
unusable, and an inherited setgid bit was judged on the wrong bit width. A
loose directory is still tightened; an over-closed one is now refused with
its remedy rather than widened, matching how an over-closed key file is
already handled. And an unreadable key or key directory now names its own
cause: every open failure previously reported a refused symlink, which is
what an operator saw for a key masked at creation.

The matrix runs first boot and fresh-process reload across umask 0000,
0022 and 0777, crossed with missing and existing ancestors and leaf, with
concurrent creators and an injected write failure. It runs in child
processes because umask is process global, and it requires at least one
successful concurrent creator so a row where every creator fails cannot
pass. All 21 rows were RED under umask 0777 before this change.

* fix(node): create and publish the node identity key through the pinned path

The node identity PEM had its own storage flow: create_dir_all with no
mode, then write, then set_permissions. That is the exists-then-write-then-
chmod sequence INV-23 prohibits, and it sits in the same ~/.gitlawb the p2p
key uses, so the umask defect the previous commit fixed for one key was
still live for the other.

Measured on the current code, as a non-root uid, all three RED:

  umask 0000  directory landed 0777, world-writable, holding the key
  umask 0022  directory landed 0755, world-traversable
  umask 0777  directory landed 0000 and the write failed with EACCES

The third is the one that mattered most: load_or_create_keypair runs before
the listener binds, so the node exited there and no p2p code was reached at
all. The umask-independence guarantee could not be demonstrated on the
shipped default while this stood.

The directory is now created through the same pin helper, at a verified
0700, and the PEM is published through the same scratch-then-link path at a
verified 0600. An existing directory that grants access beyond the owner is
tightened, which closes the other half of the same gap: a 0600 key inside a
0755 directory is still replaceable by anyone who can write that directory.
An existing key is loaded untouched and never chmodded.

Deliberately not the full ensure_key_dir. That carries the ancestor trust
walk, and importing its refusals onto a path that never had them would turn
an unsafe but currently booting deployment into a boot failure on upgrade.
Only the immediate parent goes through the pin helper, which verifies the
grandparent it is about to chmod a child of; ancestors above that keep the
existing create_dir_all behavior. The one new refusal is a group or world
writable non-sticky grandparent, which is the case where another local user
can replace the node's identity outright.

* fix(node): split p2p key config validation from live storage validation

The key-path validator answered two different questions and its callers
disagreed about what to do with the answer. Bare names, `..`, a trailing
separator and the filesystem root are properties of the configured value,
and Config::validate refuses them before the listener binds. The same
function also stat'd the key path and its parent, so a symlinked parent, a
non-directory parent or an unreadable parent exited the node as invalid
configuration, while the identical class of fault found one layer later in
load_or_create_p2p_keypair only logged and left HTTP serving.

That split was invisible from the outside and the docs described only one
half of it. Measured against the binary before this change: a symlinked
parent, a regular-file parent and an unreadable parent all exited 1 before
bind, which is the opposite of what README and .env.example promise.

Validation is now lexical only, behind a P2pKeyConfigError so the two
domains cannot be confused at a call site. Every live storage fact is left
to the load path, which already re-establishes each one on a descriptor it
opened rather than a pathname it stat'd: O_NOFOLLOW on the key open, a
regular-file check by fstat, and ELOOP or ENOTDIR at the leaf. The verdicts
are unchanged; what changes is that one policy now decides all of them.

Two supporting changes. The whole p2p port gate moves ahead of the database
connect, both arms together, because connect_db_with_retry retries forever
and left the disabled arm unreachable without a database; only p2p::start
still needs the pool. And a failed key load is now logged at error with a
stable event name and mirrored into a gauge, because the policy this commit
settles on is to keep serving HTTP with a green health check while the node
is off the p2p network, and that is only defensible if the outage is
visible to something other than a human reading startup logs.

A new integration test drives the real binary with no database and proves
both domains at the process boundary: nine lexical spellings exit before
binding, ten storage faults serve HTTP with the failure logged, and
GITLAWB_P2P_PORT=0 reaches its disabled log without touching the key tree.
Every row asserts a before/after snapshot, because a refusal that mutates
storage on its way out is its own defect and a returned error cannot show
it. Three of those rows were RED before this change.

A symlinked key directory also reports as a symlink again. Linux returns
ENOTDIR rather than ELOOP when O_NOFOLLOW meets O_DIRECTORY, so the errno
alone cannot separate a symlink from a regular file and the message had
regressed to naming the wrong cause.

* docs(node): state the two-domain key-path policy and how to see a degrade

README and .env.example promised that p2p stays off while HTTP keeps
serving when the key cannot be loaded, including for an unsafe ancestor or
working directory. Measured against the binary, that was false for a
symlinked parent, a non-directory parent and an unreadable parent, each of
which exited before binding. The preceding commit made the code match the
promise; this makes the promise precise.

All three operator surfaces now name the split the same way. A value that
cannot name a securable key file is refused before the node binds. Anything
wrong with the storage itself degrades, and the sentence says how to notice:
the node logs p2p_identity_key_load_failed at error and sets the matching
metric, /health stays green in that state, and fixing the storage and
restarting restores the same PeerId. A node quietly off the network is the
cost this policy accepts, so the docs point at the signal rather than
leaving an operator to find it in startup logs.

Two corrections while here. The key directory is tightened only when it
grants access beyond its owner; one closed too far is refused with a
chmod 700 remedy rather than widened, so the sentence no longer implies the
node will open up a directory an operator locked down. And the existing
advice to delete a key after a tightening warning is now scoped to the
loosening case it was written for: an over-closed directory is refused, not
tightened, and nothing in it was ever exposed, so following that advice
would have meant deleting a safe key and taking a second PeerId rotation
that breaks every pinned bootstrap multiaddr.

* test(node): pin the degrade signal and wait for both startup lines

Two gaps in the boundary test, both found by extending the mutation spec to
cover this commit's own guards.

The degrade rows asserted only the prose message, so renaming the event
field or dropping the level would have passed while every alert built on
the operator docs broke and the node still looked healthy. The rows now
pin the error level and the p2p_identity_key_load_failed name the docs tell
operators to watch.

The rows also waited on a single log line, which is a race: the degraded
server logs "ready" from a spawned task while the p2p gate logs its verdict
from the main task, so whichever the reader stopped at first left the other
uncaptured. It passed when the test binary ran directly and when the row ran
alone, and failed only once other work shifted the scheduling. Reads now
wait for every needle before returning.

The fix stays on the test side on purpose. Logging the p2p verdict before
the degraded server is spawned would also make the order deterministic, but
that is production sequencing changed for a test's convenience.

* fix(node): keep key publication transactional and path-policy split

Bare GITLAWB_KEY filenames publish into the working directory without
chmodding it. A directory this process created is removed if pin or
verify fails. Ancestor walks use search descriptors so a safe 0111
component is traversable. Scratch unlink is part of publish success and
is fsynced. Rotation advice is limited to writable or readable exposure,
not ordinary 0755 tightening.

* fix(node): refuse a writable cwd for a bare identity key

Publishing identity.pem into cwd must not chmod cwd, but a 0600 key is still replaceable if that directory is group or world writable. Apply the same trusted-parent check used for nominated key directories before the handle is returned.

* fix(node): load identity without following pathnames

Create already published through a directory handle. Load still used exists-then-read, which followed a symlink at the key and opened the grandparent by pathname. Open both no-follow, treat /identity.pem like a cwd publish so it reaches the filesystem, and open a search-only grandparent with the walk flags.

* fix(node): parse key paths, leave identity parents, repair special modes

A terminal `.` in GITLAWB_P2P_KEY was accepted then retargeted by Path onto
the parent directory. Identity creation imported p2p's 0700 pin onto any
existing named parent. A setgid-only 2700 key directory skipped repair and
disabled P2P. Darwin ancestor walks still required directory-list permission.

* fix(node): type-check the non-unix p2p scratch writer

The shared scratch-then-link path names pin::Pinned on every target, but
the pin module was unix-only, so gitlawb-node did not compile off Unix.

* fix(node): refuse identity path spellings that Path would retarget

GITLAWB_KEY had the same terminal-dot hole as GITLAWB_P2P_KEY, and its
tilde expansion did not refuse a suffix that escaped home. Bare filenames
stay legal.

* fix(node): create every missing identity parent on one pinned walk

The identity key path split one creation transition across two policies.
`create_dir_all` built every ancestor above the immediate parent at the
ambient umask, then `create_dir_pinned_at` ran `verify_trusted_parent` on
the last of those and refused it. Under umask 0002 that ancestor lands
0775, so the phase that made it handed it to the phase that rejects it:
the node exits before binding and the failed boot leaves the directory
behind at 0775, so the next attempt starts from state the first one made.

Replace the split with one descriptor-anchored walk. Pass 1 walks up from
the key's parent until a component opens; that descriptor is the anchor, and
it is the same fd the next step creates into. Pass 2 walks back down calling
`create_dir_pinned_at` per component, each one pinned 0700 and verified at
its achieved mode before it becomes the parent of the next, so from the
anchor down no pathname is resolved twice. Pre-existing ancestors are
adopted as-is and never chmodded, since GITLAWB_KEY is a file path rather
than a dedicated-directory setting; write-authority is still required on the
directory each component is created in.

A boot that fails past the walk now removes exactly what it created,
deepest first, through unlinkat with AT_REMOVEDIR against the descriptor
each entry was made in. A race-adopted directory is never in that list, and
AT_REMOVEDIR refuses a non-empty directory, so a leaf another boot has
published into survives. The accumulator is an out-parameter so a mid-walk
failure and a post-walk failure reach the same single rollback arm.

Two consequences worth naming. A symlink at the deepest existing ancestor is
refused when the missing suffix reaches it, where `create_dir_all` followed
it. Ancestors ABOVE that anchor are still resolved by pathname and their
symlinks still followed, unchanged from before this walk, because O_NOFOLLOW
binds only the final component of a path-based open; closing that would mean
an openat chain from the root and the full ancestor policy that GITLAWB_KEY
paths deliberately do not get. The doc comment now says so rather than
claiming more than the code does. And a losing concurrent first boot can
remove an intermediate another boot adopted but has not yet written into,
turning that boot's start into an ENOENT failure; both still fail closed.

Also replace the root-adjacent test. It proved a pathname property by
running the full mutating path against the real filesystem root and
assuming the runner lacks permission. Under euid 0, common in build
containers, `/` is accepted (root-owned, 0755, no group or other write
bit), the production path returns Ok, the test's panic arm fires before its
cleanup assertion, and a real private key is left at /identity.pem, after
which the leading exists() check makes every later run a silent skip. The
routing is now asserted lexically through a named predicate, with no
filesystem access and no dependence on euid.

The matrix covers one, two and three missing levels under umasks 0000,
0002, 0022 and 0777, a pre-existing 0755 ancestor that must stay 0755, a
relative multi-level path, a symlinked deepest ancestor, refusal without
partial mutation, and a populated leaf whose removal must be refused and
reported. Created-directory modes are asserted by exact equality, and each
success row reloads in a fresh process and requires the same DID.

* test(node): race real concurrent first boots of the identity key

The rollback added with the pinned walk can remove an intermediate that a
concurrent boot has already adopted, and until now that was reasoned about
rather than executed. Nothing in the suite started two real first boots
against the same key path.

Add a `concurrent-first-boot` layout to the self-exec fixture and a driver
that races several children through `load_or_create_keypair_at` on one
missing multi-level path. A two-phase file rendezvous puts every child
inside a spin loop before any of them is released, so the spread across the
racing call is one loop iteration rather than process-startup jitter. Three
arms: unaided boots, one injected write failure against one unaided boot,
and four injected failures against one, which is what actually opens the
window.

The assertions are properties of every legal outcome, never one
interleaving, so the test is deterministic even though the race is not. A
boot that claims success has a parseable 0600 key on disk, all successes
agree on the DID, surviving directories are exactly 0700 with no scratch
residue, and if no key was published then nothing claimed success and a
retry must still work. Observation counters are printed, not asserted, so a
run that never hits the window says so rather than passing as proof.

Two things the race showed that reading could not. The documented window is
real: a boot was refused with "No such file or directory" on its publish
after a loser rolled back the leaf it had adopted. But the more common shape
is one hop earlier, the loser removing an adopted intermediate so the ENOENT
lands on the next component's creation instead. And under enough concurrent
rollbacks every boot can fail, not merely the losers, so several nodes
starting against a shared first-boot path can all need a restart. Two-child
racing never opened the window at all in 500 iterations.

* fix(node): resolve identity key path components one at a time

`O_NOFOLLOW` binds only the final component of a path-based `open(2)`, which
is all the kernel offers for a multi-component pathname. Three sites resolved
a whole configured path in one open and so guarded only the last position:
the load path, the existing-named-parent fast path, and the walk that creates
missing parents. Every interior symlink was followed, and `verify_trusted_parent`
then attested the directory that was reached rather than the path taken to it.

That is enough to substitute the node's whole identity, not merely misplace
its key. Load and create resolve the configured pathname identically, so
repointing one interior link redirects both. Point it at a directory holding
another PEM and the node boots as that DID while its real key sits untouched
on disk and nothing is logged as wrong. Point it at an empty directory and
the node mints a fresh identity and reports a first boot. Neither needs the
attacker to read anything: ownership and mode checks still pass, because they
run on the object at the end of the path.

Resolve each component with `openat` and `O_NOFOLLOW` against the descriptor
of the one before it, from `/` for an absolute path or the working directory
for a relative one, so a symlink is refused at every position. Interior
components open search-only and the final component keeps the caller's flags,
so a legitimate 0111 ancestor still resolves and the leaf descriptor is still
the one that gets fsynced and chmodded.

Components are adopted with no ownership or mode judgment. The only new
refusal is a symlink on the key path: a shared 0775 volume, a foreign-owned
ancestor and `/etc` all keep working, because `GITLAWB_KEY` is a file path
rather than a dedicated-directory setting. `verify_trusted_parent` stays
exactly where it was, on the descriptor a child is created in. The p2p key
path is untouched; it already walked component by component.

This is pre-existing rather than a regression: the same probe against the
branch point produces the same result. It is fixed here because closing it
replaces the walk this branch just added, so splitting it would mean
reviewing that code twice.

Covered by rows for an interior symlink with the next level present, an
absolute target escaping the base, a two-link chain, the fast path, a
relative configured path, and both load-path variants asserting on the DID
the node ends up presenting rather than on a mode or an error string. The
cases that were already refused are pinned too, including a symlink at the
immediate parent and a dangling link, neither of which had a test before.

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-04 17:02:15 +00:00
beardthelion 8a98cab243 test(node): cover the component walk in validated_repo_disk_path (#411) (#441)
* test(node): cover the component walk in validated_repo_disk_path

* chore: re-trigger code quality check
2026-11-04 16:38:52 +00:00
JATMNandClaude Opus 5.5 623b600835 fix(deps): bump libp2p to pick up hickory 0.26 and serde_with 3.22 (#518)
hickory-proto/hickory-resolver 0.25.2 were pulled in only by libp2p-dns
0.44. libp2p-dns 0.45 moves to hickory 0.26 but needs libp2p-core 0.44
and libp2p-identity 0.3, so the node's direct libp2p crates move to the
matching release line (core 0.44, gossipsub 0.50.1, identify 0.48,
identity 0.3, kad 0.49, quic 0.14, swarm 0.48, dns 0.45). multiaddr goes
to 0.19 to match libp2p-core and avoid compiling two copies. No source
changes were needed.

Lockfile: hickory-proto/resolver 0.25.2 -> 0.26.3 (GHSA-q2qq-hmj6-3wpp /
RUSTSEC-2026-0119, GHSA-6f2x-v7q7-m7m5), serde_with 3.18.0 -> 3.22.0
(GHSA-7gcf-g7xr-8hxj).

With the fix in, drop the RUSTSEC-2026-0118/0119 ignores from
.cargo/audit.toml as #76 requires.

Closes #76

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 16:37:21 +00:00
beardthelionandKevin Codex 9bda2e103c fix(node): authenticate gossip ref-update events before writing them (#325)
* feat(node): sign and verify gossip ref-update events

Add an optional sig field to RefUpdateEvent carrying a base64 Ed25519
signature over the event's own fields, plus signing_bytes, the single
producer of signing input on both sides, and sign/verify helpers.

The field is optional and skipped when absent, following the owner_did
precedent from #144, so an event from a signing node still deserializes
on a peer that predates this change. A nested envelope would have broken
that and is deliberately not used.

Verification resolves the key from the claimed node_did, so a signature
that does not bind the DID it claims is refused. Non-did:key and
unresolvable DIDs are refused with the peers-table denial sentences,
built from PeerWriteDenied rather than retyped so the two surfaces
cannot drift apart.

A golden test pins the signing input: a field reorder changes what every
peer signs, which no round-trip test can see.

Helpers are not wired into the ingest or emit paths yet.

* fix(node): authenticate gossip ref-update events before writing them

The gossipsub ingest path accepted a ref-update from any peer and wrote
it, while the HTTP route carrying the same payload checks the signature
against the claimed DID, requires a known peer, and validates the repo
slug. Extract the path into ingest_ref_update and apply the same three
gates in that order, so both transports answer the same input the same
way.

A present signature must verify in either mode. An absent one is refused
when GITLAWB_REQUIRE_SIGNED_PEER_WRITES is set and otherwise accepted
with a warning naming the flag, so peers that predate signing keep
federating until the fleet is ready. The DID-method refusal runs right
after parse in both modes and reuses the peers-table denial sentences.

Rejection writes nothing: neither a ref-update row nor a sync-queue
entry. Those are separate sinks and the tests assert them separately.

from_peer's comment now says it records the forwarder, not the author.

* fix(node): bound gossip ingest per source and sign outbound events

Brake the ingest path at 60 events per minute per forwarding peer,
checked ahead of the parse and the signature work. Verifying a signature
is the expensive step, so a brake behind it would sell an unauthenticated
flood the CPU the brake exists to protect. Rejection writes nothing to
either sink.

Sign events on the way out, so a fleet running with enforcement on can
verify what its peers publish. A signing failure warns and skips the
publish rather than emitting something a verifying peer would drop.

The emit-to-ingest round trip is asserted end to end, which is what
proves both sides agree on the signing input by execution rather than by
inspection.

* test(node): cover the did-method gate and correct overclaiming comments

The did-method gate had no load-bearing test: deleting it left the suite
green, because the only non-did:key case was signed, so verification
resolved the DID internally and returned the same refusal. Add the case
only the gate decides, an unsigned non-did:key event from a registered
peer with enforcement off, and correct the comment that claimed the
other test covered it.

Assert the limiter's source bound, which no test pinned, and make the
backward-compat test able to see an added field by parsing under a
deny_unknown_fields reader as well as a permissive one.

Say plainly at the peers check that authentication is not authorization:
a self-registered did:key passes it, so what the gate buys is blocking an
unregistered DID and, with the signature, blocking impersonation of an
existing peer. Drop the claim that the unsigned-acceptance warning is
covered by a test, because it is not.

An outcome of Accepted now means the row was stored; a failed write
reports itself instead of reading as success.

* fix(node): charge the ingest budget to the author, not the mesh edge

A single 60-per-forwarder budget was both too tight and pointed at the
wrong principal. Pushes emit one gossip event per ref, so a push touching
more than 60 refs lost its tail with no signal to the publisher, and
because gossipsub re-shares mesh-wide, a flood routed through an honest
neighbour spent that neighbour's budget on every receiver.

Split it. A loose pre-parse bound on the forwarding peer still caps the
CPU spent before anything is parsed or verified, which is all a
free-to-mint identity can honestly buy. A tighter bound on the
authenticated node_did sits after verification and the peer gate, so the
two durable writes are charged to whoever actually authored them, and it
is sized to admit a large push whole.

Both rejections now name the budget and its window, so a dropped
ref-update says which bound refused it rather than vanishing.

Replace the per-event full peers scan with a keyed lookup on the same
column; upsert_peer shares it.

A push above the author bound still loses its tail, now loudly. The two
transports continue to disagree about events per push: gossip publishes
per ref while sync/notify batches.

* docs(node): the did-method gate runs after the parse, not before it

Two test comments called it the pre-parse did-method gate. The parse is
above it and the gate is below; what is true is that it runs before the
signature branch, in both enforcement modes. The pre-parse label belongs
to the forwarder brake, which is genuinely the first statement, and the
nearby correct usage is where the wrong one came from.

* fix(node): charge the gossip write budget only to a proven author

An unsigned ref-update event's node_did is asserted, not proven. The author
budget was debited on that key in both signature arms, so a flood of unsigned
events naming a registered peer drained that peer's budget and its own genuine
signed updates were shed. The attacker chose the victim.

The author budget is now charged only when the signature verified. Unsigned
compatibility traffic charges a separate forwarder-keyed bucket sized at
GOSSIP_UNSIGNED_SOURCE_MAX_EVENTS (1500), above the per-author cap because a
forwarder aggregates many authors, and below the pre-parse brake; the
compile-time assert now pins all three in one chain. The shed carries its own
IngestOutcome variant so a forwarder is never named as an author.

validate_repo_slug moves above both the signature match and peer_exists, so a
malformed event costs no database round trip rather than merely being charged
to nobody. A test-only peer_exists tally proves that placement, which an
outcome assertion cannot distinguish.

* fix(node): version the signed ref-update format and refuse unknown versions

The signed payload carried no version and verify_ref_update knew one
representation, so the next signed-field addition would partition the mesh
mid-rollout: an upgraded publisher signs bytes an older receiver cannot
reproduce, and the older receiver reports a genuine event as a bad signature.

RefUpdateEvent now carries v, skipped when zero, so a v0 event's wire and
signing bytes stay byte-identical to the versionless form. GOLDEN_SIGNING_BYTES
is unchanged and every signature already issued still verifies. A frozen
artifact signed before the field existed is checked into the suite and verified
without being re-signed, since a signature a test just produced is
self-consistent by construction and proves nothing about compatibility.

An event whose version this build cannot interpret is now refused in its own
words, ahead of every other gate: no field below an unknown version has known
meaning. Without that, a v1 event was admitted outright rather than merely
mislabelled.

* docs(node): describe the real signing handoff at the publish site

The comment claimed signing was future work and the event went out unsigned.
It does not: the swarm loop signs via p2p::signed_publish_bytes immediately
before publishing, and skips the publish entirely when signing fails. Leaving
sig None here is correct, and the comment now says why rather than describing
a state the code left behind.

* docs: GITLAWB_REQUIRE_SIGNED_PEER_WRITES spans both transports

The flag gates gossip ref-update ingest as well as the HTTP announce and sync
routes, but README and .env.example still scoped it to those two routes. An
operator who upgraded the HTTP peers and then enabled it would silently drop
gossip from every publisher still running an old build, with no error visible
on the publisher's side because gossip carries no response.

Documents the cross-transport behavior and the one ordering requirement that
follows: upgrade every gossip publisher before enabling.

* fix(review): count gossip ingest outcomes and sweep the ingest limiters

Every IngestOutcome reached tracing and nothing else, so gossip-ingest health
was invisible on /metrics, the only surface this daemon exposes to a dashboard
or an alert. Adds gitlawb_gossip_ingest_events_total{outcome} alongside the
existing sync-queue and webhook counters, with the label derived from an
exhaustive match so a seventh variant fails to compile rather than shipping
uncounted. The reason strings stay in the log line; they are sender-shaped and
would make the label set unbounded.

The three ingest limiters were built as locals in the swarm task, so
sweep_rate_limiters could not reach them and their keys stayed resident until
the 200k ceiling. The swarm loop now sweeps them on its own interval, and
cleanup iterates a destructured array so a fourth limiter breaks the build
instead of being silently missed, which is the failure sweep_rate_limiters'
own comment records against the /ipfs limiter.

* test(node): close four gaps the review found in this PR's own guards

The frozen signing constants pinned only the all-Some optional shape, but the
sole publish site always emits cert_id None, so no constant covered the
encoding every real event uses. Proven during review: adding
skip_serializing_if to cert_id left the suite green while the signing input
changed for 100% of production events. A second constant now pins the
null-carrying form.

The legacy artifact's provenance assertion claimed re-capturing it from current
code was what the assertion refused. It was not: skip-when-zero means a
regenerated artifact also carries no version key, so the check passed and the
claim was unfounded. A SHA-256 over the frozen bytes does the job the message
promised, and the message now states only what it checks.

WriteFailed was the one outcome variant no test constructed, so the
independent-writes property it exists to express was unexercised. Both
directions are now driven by failing a real write.

The publish arm's body is extracted so the exact bytes handed to gossipsub are
asserted signed and accepted by ingest under enforcement. The select! dispatch
and the require_signed threading from main.rs still need a live swarm and are
named as uncovered rather than implied covered.

* fix(review): close the remaining findings from the round on this PR

The unsigned-acceptance warning fired before two gates that could still drop
the event, so a message the operator reads as an admission could be followed by
a forwarder-budget shed or an unknown-peer refusal. It now fires only once the
event is genuinely admitted, and a test pins that it fires exactly once, which
nothing checked before.

The publish site hardcoded the format version while the ingest gate compared
against the constant, so emitter and gate could drift apart silently. The
publish site now reads the constant.

The known-peer gate changed a federation precondition without documenting it: a
gossip publisher must also be a known peer on the receiving node, via bootstrap
or a prior announce, or its events are dropped in both flag modes. That is now
in the README beside the rollout ordering, derived from what the ingest path
does rather than from another docstring.

Six tests each hand-rolled the same both-modes rejection loop; they now share
one helper. The log-capture helper was left under a tmp_ prefix from the round
that added it and is renamed, since scaffolding vocabulary should not ship.

* fix(review): report unsigned gossip admissions as their own outcome and metric (#325)

An unsigned event that survives the rolling-upgrade window was returned as
IngestOutcome::Accepted, so `gitlawb_gossip_ingest_events_total{outcome="accepted"}`
mixed signature-verified admissions with unauthenticated legacy traffic. An
operator could not tell whether the fleet still relied on the compatibility
allowance or authenticated gossip was healthy.

Add IngestOutcome::UnsignedAdmitted with its own `unsigned_admitted` metric
label, returned when an unsigned event passes every gate and the writes land.
`Accepted` is now reserved for signature-verified events. Propagate through the
swarm-loop logging and the tests that asserted `Accepted` for unsigned
admissions.

Both guards proven load-bearing by execution: reverting the return-site change
goes RED on flag_off_unsigned_known_peer_event_is_accepted ("got Accepted"),
and giving UnsignedAdmitted the "accepted" label goes RED on
every_ingest_outcome_carries_a_distinct_metric_label.

* fix(node): shed an over-budget gossip author before the peer lookup (#325)

The author brake sat below `db.peer_exists`, so a signed author already past its
budget still paid a Postgres round trip before being refused. That is a brake
behind the work it bounds: the lookup is done for a request that was never going
to be admitted, and a signed author can drive it at the pre-parse source rate,
further with peer-id rotation.

The brake now answers first, and the answer is a READ rather than the charge.
`check` inserts a window for a key it has never seen, so probing with it here
would let a flood of self-minted signed DIDs occupy the bounded author map before
`peer_exists` could refuse them, and legitimate new authors would be shed once it
filled. That would have made the remedy worse than the finding. `is_over_budget`
allocates nothing, reports an unseen key as within budget, and leaves the charge
itself below the lookup, unmoved. The only behaviour that changes is WHEN an
already-over-budget author is refused.

Two guards, both proven load-bearing by execution. Removing the early shed makes
`an_over_budget_author_is_shed_without_a_peer_lookup` red on the call counter,
which observes the lookup not happening rather than restating the ordering.
Swapping the read for `check` makes `the_early_author_check_does_not_track_an_unseen_did`
red on the map size, which is the must-not that keeps the brake from becoming its
own memory-fill surface.

Found by a cross-family refute pass on the pushed head.

* chore: retrigger checks after a CodeQL infrastructure failure

The swift analysis job on run 32041989078 failed in "Set up job", before
checkout or any analysis step. GitHub refuses a rerun on default-setup
(dynamic) runs, so a new head is the only way to get a fresh scan.

* fix(deps): bump h2 to 0.4.19 for RUSTSEC-2026-0258

The branch lockfile still pinned h2 0.4.13, so cargo audit failed on
every push while main had already moved on. This is lockfile-only; no
source change in the gossip auth work.

* chore: re-trigger code quality check

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-11-04 16:34:10 +00:00
JATMNandClaude Opus 5.5 151d688387 fix(deps): bump aws-smithy-json to 0.62.7 (GHSA-8ffr-xgwf-xj56) (#516)
Cargo.lock resolved aws-smithy-json 0.61.9 (via aws-sdk-s3 1.119.0) and
0.62.5 (via aws-config and the sso/ssooidc/sts clients). Both are in the
range affected by GHSA-8ffr-xgwf-xj56 / CVE-2026-18140 (<= 0.62.6):
uncontrolled recursion when skipping unknown keys in deeply nested JSON.

0.62.7 requires aws-smithy-runtime-api ^1.12.3, so the AWS SDK moves as
a set to the newest releases that still build on our 1.91 MSRV (they
declare rust-version 1.91.1; later releases need 1.94.1):
aws-config 1.8.18, aws-sdk-s3 1.137.0 (which itself requires
aws-smithy-json ^0.62.7), aws-sdk-sso 1.102.0, aws-sdk-ssooidc 1.104.0,
aws-sdk-sts 1.107.0, aws-sigv4 1.4.5, and matching aws-smithy-* crates.

aws-sigv4 1.4.5 moves to p256 0.13, which drops crypto-bigint 0.4.9 from
the lockfile. Update the RUSTSEC-2026-0387 note in .cargo/audit.toml to
match; the ignore is still needed for crypto-bigint 0.5.5.

Lockfile and audit-config comment only; no manifest or source changes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 15:14:11 +00:00
JATMNandClaude Opus 5.5 4d024e01bc fix(deps): resolve cargo audit failures on main (#514)
* fix(deps): resolve cargo audit failures on main

Update quinn-proto 0.11.19, rustls 0.23.46, rustls-webpki 0.103.16,
webpki-roots 1.0.11, zstd 0.13.4, zstd-safe 7.4.0 and zstd-sys 2.1.2 in
Cargo.lock to clear RUSTSEC-2026-0338..0342, 0346..0349, 0381, 0382,
0391, 0398, 0403 and 0410.

Ignore RUSTSEC-2026-0387 (crypto-bigint): the fix is only in 0.7.x, and
no dependent compatible with our tree uses it. alloy-signer (through
2.5.0) pins elliptic-curve 0.13, and aws-sigv4 >=1.5 requires Rust
1.94.1, above the 1.91 MSRV.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(audit): document RUSTSEC-2026-0387 reachability and graph-based removal

Correct the aws-sigv4 boundary (1.6.4 is the first release on
crypto-bigint 0.7; 1.5.x-1.6.3 still need ^0.5.4), mark the ignore as
reachable through the k256 PrivateKeySigner and SigV4a signing paths,
and tie removal to Cargo.lock having no crypto-bigint below 0.7.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:26:02 +08:00
beardthelion 6588590813 chore: update rustls 0.23.37 → 0.23.45 to resolve RUSTSEC-2026-0285 (#455) 2026-11-04 01:23:10 +00:00
beardthelion 02fba70bc6 fix(node): validate the fork name before spending the proof
The barrier landed after `proof.consume` and after `repo_store.acquire`, so a
name the character allowlist admits but `validate_repo_name` refuses burned a
valid iCaptcha proof and paid a source-repo download before its 400. The
handler's own header comment promises the opposite: a fork rejected for a bad
name never burns a proof. Reported by CodeRabbit on the review of a7257dce.

It now runs with the other admissibility checks, above both.

The regression test pins the ORDER without iCaptcha plumbing. Seed a repo row
whose bytes are not on disk: late validation lets the acquire fail first and
the caller sees a 500 from git, so only early validation can produce the 400
the test asserts. Verified load-bearing by degrading the early call to
`unwrap_or_else`, which reddens it.

Both name rules are kept. CodeRabbit also suggested deleting the character
allowlist as a strict subset of `validate_repo_name`, and that is not the
relation between them: the allowlist rejects a dot, so `v1.2.3` and `my.repo`
are refused today while `validate_repo_name` accepts both, and the allowlist
accepts a non-ASCII alphanumeric that `validate_repo_name` rejects. Removing
it would newly admit dotted fork names, which is a behaviour change and not a
cleanup. A comment now records why the two coexist.

The empty-name fixture also generates its owner DID per run. It writes to a
fixed `/tmp/<owner_slug>`, so a shared constant let two concurrent `cargo
test` processes delete each other's repo mid-test (reported by Greptile).
2026-09-07 22:54:52 -05:00
beardthelion a7257dce20 fix(node): hold fork to the same repo-name rules as every other creation route
fork_repo built its clone destination with store::repo_disk_path, a raw
join with no validation, while create_repo and the sync mirror path both
go through validated_repo_disk_path. Its own name check is
`.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')`, which is
Unicode-aware and has no length or leading-character rule, so it admits
four shapes validate_repo_name refuses: the empty string (all() is
vacuously true on it), a non-ASCII alphanumeric such as "cafe" with an
acute e, a leading '-', and a name over the 100-char bound.

The empty name was the reachable defect. Driven through the production
router with a real Ed25519 signature, a `{"name":""}` fork returned 201
Created with a repo row whose name is "" and a clone_url ending in
`/.git`, materialized at `<repos_dir>/<owner_slug>/.git`. It now returns
400 and creates neither the row nor the directory.

This is a deliberate narrowing, not only an empty-name fix: the other
three shapes above are now 400 on fork where they previously reached the
raw join. None of them could ever have been created through create_repo,
which fails at repo_store.init on the same validator, and no existing row
can carry such a name (create_repo validates before its DB insert, and
mirror rows go through validate_repo_slug), so no repo becomes
unforkable. A fork that defaults to source.name is unaffected.

The traversal shape of the same join is NOT reachable and is not what
this changes: forker_did comes from AuthenticatedDid, which auth/mod.rs
inserts only after Did::to_verifying_key resolves a multibase-decodable
did:key, so it can carry neither ".." nor "/".

repo_disk_path is now cfg(test). It has no production caller left, and
the attribute turns the next one into a compile error rather than a
silent bypass. Tests keep it because a fixture that must escape repos_dir
cannot be built with the validated form.

Two tests. A unit test on the mounted handler, and an end-to-end test
through server::build_router with real signatures that covers all four
refused shapes plus a positive control asserting an ordinary name still
forks to disk and to a row. Both are mutation-verified in both
directions: reverting to the raw join reddens the refusals, and forcing
fork to always refuse reddens the control.

Closed #272 fixed this class on the sync route and pointed at
repo_disk_path as the sanitizing convention; it is in fact the
unvalidated join, and the fork route was never scoped.
2026-09-07 20:45:37 -05:00
Kevin Codex bfc44f926d Merge pull request #381 from Gitlawb/fix/gl-whoami-capped-body-read
fix(gl): read whoami error bodies through CappedBody.text
2026-08-26 13:26:50 +08:00
beardthelion 65f5989a1d test(node): pin owner-push off in receive-pack lock tests
These lock-path proofs use non-owner proof DIDs. With enforce_owner_push
defaulting on, they fail before receive-pack runs.
2026-08-25 10:27:48 -05:00
beardthelion 4725146db2 fix(gl): read whoami error bodies through CappedBody.text
#173 changed read_body_capped to return CappedBody; peer.rs and sync.rs
were updated but whoami.rs was not, leaving main red after the #223 merge.
2026-08-25 10:04:20 -05:00
123 changed files with 33282 additions and 2795 deletions

No files matched your search

+19 -15
View File
@@ -8,24 +8,9 @@
# The audit job stays hard-fail: only the advisories explicitly listed below are
# suppressed, so any NEW advisory still breaks CI. Each entry below has no
# available upstream fix; everything fixable is fixed in Cargo.lock instead.
#
# Tracking: the hickory ignores are reachable accepted risk. A scheduled re-check
# (.github/workflows/audit-schedule.yml) runs without these ignores and fails if
# the lockfile moves off the pinned vulnerable versions while the ignores remain.
[advisories]
ignore = [
# hickory-proto 0.25.2 (pulled by libp2p-dns 0.44 -> hickory-resolver 0.25).
# REACHABLE: the node wraps its QUIC transport in the system DNS resolver
# (crates/gitlawb-node/src/p2p/mod.rs:238), so a malicious DNS response can
# trigger these DoS paths. No fix is available: the latest libp2p-dns on
# crates.io is 0.44 and pins hickory 0.25; the fix requires hickory >=0.26.1.
# REMOVE both once libp2p-dns ships a release using hickory 0.26 (verified
# 2026-06-21: cargo update -p hickory-proto locks 0 packages; still 0.25.2).
# Tracking: https://github.com/Gitlawb/node/issues/76
"RUSTSEC-2026-0118", # hickory NSEC3 closest-encloser unbounded loop
"RUSTSEC-2026-0119", # hickory O(n^2) name-compression CPU exhaustion
# rsa 0.9.10 (Marvin timing sidechannel). Present in Cargo.lock because sqlx
# resolves sqlx-mysql unconditionally, and sqlx-mysql depends on rsa. cargo
# audit scans the lockfile, so it flags rsa even though it is NOT linked into
@@ -35,4 +20,23 @@ ignore = [
# ever built with the `mysql` feature, or any other consumer of rsa enters
# the build, at which point it becomes a real reachable advisory.
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
# crypto-bigint 0.5.5 (secret-dependent branches in operations meant
# to be constant-time). REACHABLE accepted risk, on two paths:
# - alloy-signer -> elliptic-curve 0.13 -> crypto-bigint 0.5: the k256
# PrivateKeySigner that signs transactions in `gl name`
# (crates/gl/src/name.rs) and the node operator
# (crates/gitlawb-node/src/operator.rs).
# - aws-sigv4 (`sigv4a` feature, crates/gitlawb-node/Cargo.toml) ->
# crypto-bigint 0.5 (directly and via p256 0.13): SigV4a key
# derivation, used only for requests to endpoints that require SigV4a
# (e.g. S3 Multi-Region Access Points).
# Exploiting it needs local timing observation of those signing operations.
# The fix is crypto-bigint >=0.7.6, and no compatible dependent uses it:
# every alloy-signer release (through 2.5.0) requires elliptic-curve ^0.13,
# and aws-sigv4 1.6.4 is the first release on crypto-bigint 0.7 (1.5.x -
# 1.6.3 still require ^0.5.4), but it needs Rust 1.94.1, above our 1.91 MSRV
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
]
+56 -8
View File
@@ -1,12 +1,41 @@
# gitlawb node — environment variables
# Twigpine node — environment variables
# Copy to .env and fill in your values.
# All variables are optional unless marked REQUIRED.
# ── Node identity ─────────────────────────────────────────────────────────
# Path to the node's Ed25519 keypair PEM file.
# Path to the node's Ed25519 keypair PEM file. Must name a key file, not a
# directory: a trailing slash, a final `.` or `..`, and `..` traversal are
# refused so the path cannot retarget a parent. A `~/` value must stay under
# home after expansion. Bare `identity.pem` is still legal.
# Generate with: gl identity new
GITLAWB_KEY=/data/keys/identity.pem
# Path to the node's persistent libp2p identity key file. Must include a
# directory, because the node will not keep its p2p identity key in the working
# directory. A value that cannot name a securable key file (a bare filename,
# `..` traversal, a trailing slash, the filesystem root, or a `~/` path that
# escapes home) is refused before the node binds, so the process exits.
# Anything wrong with the storage itself (a symlinked or non-directory parent,
# an unsafe cwd or ancestor, an unreadable or malformed key) leaves p2p off
# while HTTP keeps serving, logged at error as p2p_identity_key_load_failed and
# counted by the gitlawb_p2p_identity_key_load_failed metric. /health still
# reports healthy in that state, so alert on the event or the metric. Fixing
# the storage and restarting restores the same PeerId.
# On Unix it is created 0600 inside a 0700 directory. A key directory that
# grants access beyond its owner is tightened to 0700 on start; one closed too
# far is refused with a chmod 700 remedy rather than widened. On other
# platforms no permissions are enforced.
# If the node logs that it tightened a key directory that was GROUP OR WORLD
# WRITABLE, treat the key that was sitting there as possibly exposed: delete
# it so a fresh identity is generated on the next start. An ordinary 0755
# directory is tightened too, but others could not read a 0600 key or replace
# its directory entry, so do not rotate for that case. A directory that was
# merely closed too far is refused, not tightened, and no key in it was ever
# exposed.
# Keep it on a persistent volume so the PeerId survives redeploys.
# Default: ~/.gitlawb/p2p.key
#GITLAWB_P2P_KEY=/data/keys/p2p.key
# Publicly reachable URL of this node (used in peer announcements)
GITLAWB_PUBLIC_URL=https://your-node.example.com
@@ -24,11 +53,26 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
# ── Database pool & startup resilience ────────────────────────────────────
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
# connections open lazily. Size against the DB server's max_connections,
# remembering admin tooling opens its own pool. Each concurrent write pins one
# connection for its whole duration (the connection-affine advisory lock), so the
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
# headroom — keep this comfortably above that (default 48 for pushes 32).
# remembering admin tooling opens its own pool.
GITLAWB_DB_MAX_CONNECTIONS=48
# Maximum connections in the DEDICATED advisory-lock pool, separate from the
# pool above. Every in-flight repo write pins one connection here for its whole
# duration, so this is a hard ceiling on simultaneous writes node-wide: size it
# to expected peak concurrent writers, not small. Keeping it separate is what
# stops a push burst from starving ordinary request handlers. Budget
# (GITLAWB_DB_MAX_CONNECTIONS + this) per node against the server's
# max_connections, times node count, plus admin tooling.
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=40
# Upper bound, in seconds, on any object-storage transfer that runs while a
# per-repo write lock is HELD (the archive download inside acquire_write and the
# upload inside release). These were free before the lock's connection was
# pinned to the guard; now an unbounded stall holds a lock-pool slot, and enough
# stalls deny every write on the node. The bound applies PER SPAN and there are
# two (the acquire-side refresh, which covers the existence check and download
# together, and the release-side upload), so worst-case slot occupancy is about
# twice this value plus the git work between them. Read it together with the pool
# size above and with GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS=300
# Seconds a request waits for a pool connection before failing with 503.
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
@@ -88,8 +132,12 @@ GITLAWB_P2P_PORT=7546
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
GITLAWB_BOOTSTRAP_PEERS=
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
# Require signatures on peer announce/sync write routes. Keep false during
# rolling upgrades so existing live nodes can still communicate.
# Require a peer write to prove its DID on every transport: RFC 9421 signatures
# on the announce/sync routes, and an Ed25519 payload signature on gossip
# ref-update events. Keep false during rolling upgrades so existing live nodes
# can still communicate. Upgrade every gossip-publishing peer to a build that
# signs events before setting this true, or their updates are dropped on
# arrival with no error visible on their side.
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# Require the authenticated pusher to be the repo owner on git-receive-pack.
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report
description: Report a defect in a Gitlawb crate or node behavior
description: Report a defect in a Twigpine crate or node behavior
labels: ["bug"]
body:
- type: markdown
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/Gitlawb/node/blob/main/SECURITY.md
url: https://github.com/Twigpine/node/blob/main/SECURITY.md
about: Please report security issues privately per our security policy, not as a public issue.
- name: Questions & discussion
url: https://github.com/Gitlawb/node/discussions
url: https://github.com/Twigpine/node/discussions
about: For usage questions and open-ended discussion.
+1 -1
View File
@@ -1,5 +1,5 @@
<!--
Thanks for contributing to Gitlawb. Keep PRs focused on one change.
Thanks for contributing to Twigpine. Keep PRs focused on one change.
Protocol-level changes (identity, signatures, wire format) should start
as an issue before code — see CONTRIBUTING.md.
-->
+1 -1
View File
@@ -124,7 +124,7 @@ jobs:
"",
lines,
"",
"See [CONTRIBUTING.md](https://github.com/Gitlawb/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
"See [CONTRIBUTING.md](https://github.com/Twigpine/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
].join("\n");
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: comment });
+14 -14
View File
@@ -31,7 +31,7 @@ jobs:
# Skipped on manual dispatch: a backfill must never create or advance a
# release PR as a side effect (npm-publish tolerates the skipped need via
# !cancelled(), and every other job gates on release_created == 'true').
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
if: ${{ github.repository == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
@@ -64,7 +64,7 @@ jobs:
sync-release-lock:
name: Sync Cargo.lock on the release PR
needs: release-please
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
if: ${{ !cancelled() && github.repository == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -669,7 +669,7 @@ jobs:
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: Gitlawb/homebrew-tap
repository: Twigpine/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_PAT }}
path: tap
persist-credentials: false
@@ -695,8 +695,8 @@ jobs:
mkdir -p tap/Formula
cat > tap/Formula/gl.rb <<EOF
class Gl < Formula
desc "Gitlawb CLI — decentralized git for AI agents and developers"
homepage "https://gitlawb.com"
desc "Twigpine CLI — decentralized git for AI agents and developers"
homepage "https://twigpine.com"
version "${VERSION}"
license "MIT OR Apache-2.0"
@@ -760,7 +760,7 @@ jobs:
fi
git commit -m "gl ${VERSION}"
# Credentials are not persisted in .git/config; supply the token only for the push.
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Gitlawb/homebrew-tap.git" HEAD:main
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Twigpine/homebrew-tap.git" HEAD:main
web-sync:
name: Sync web (install scripts + version)
@@ -794,7 +794,7 @@ jobs:
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: Gitlawb/web
repository: Twigpine/web
token: ${{ secrets.WEB_SYNC_PAT }}
path: web
persist-credentials: false
@@ -837,7 +837,7 @@ jobs:
# One-time, idempotent drift fixes: drop orphaned binaries, fix stale link.
rm -rf web/public/bin
if [ -f web/public/skill.md ]; then
sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/node/releases#g' web/public/skill.md
sed -i 's#github.com/gitlawb/releases#github.com/Twigpine/node/releases#g' web/public/skill.md
fi
cd web
@@ -851,16 +851,16 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git commit -m "chore: sync gitlawb ${TAG} (install scripts + version)"
git commit -m "chore: sync Twigpine ${TAG} (install scripts + version)"
# Credentials are not persisted in .git/config; supply the token only for the push.
git push --force "https://x-access-token:${GH_TOKEN}@github.com/Gitlawb/web.git" "HEAD:$branch"
gh pr create --repo Gitlawb/web --head "$branch" \
--title "Sync gitlawb ${TAG}" \
--body "Automated sync from Gitlawb/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \
git push --force "https://x-access-token:${GH_TOKEN}@github.com/Twigpine/web.git" "HEAD:$branch"
gh pr create --repo Twigpine/web --head "$branch" \
--title "Sync Twigpine ${TAG}" \
--body "Automated sync from Twigpine/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \
|| {
# Only swallow the "PR already exists" case; surface auth/API failures
# (a failed gh pr list must not be misread as "PR exists").
pr_count="$(gh pr list --repo Gitlawb/web --head "$branch" --state open --json number --jq 'length')" || exit 1
pr_count="$(gh pr list --repo Twigpine/web --head "$branch" --state open --json number --jq 'length')" || exit 1
if [ "$pr_count" != "0" ]; then
echo "PR for $branch already exists; branch was updated."
else
+4 -4
View File
@@ -1,12 +1,12 @@
# Contributing to gitlawb
# Contributing to Twigpine
Thanks for your interest in contributing. gitlawb is an open protocol — agents and humans welcome.
Thanks for your interest in contributing. Twigpine is an open protocol — agents and humans welcome.
## Quick start for contributors
```sh
# Clone and build
git clone https://github.com/Gitlawb/node
git clone https://github.com/Twigpine/node
cd node
# Build everything
@@ -38,7 +38,7 @@ docs/ Operator guides
scripts/ Build helpers
```
Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://github.com/Gitlawb/contracts).
Smart contracts live in a separate repo: [github.com/Twigpine/contracts](https://github.com/Twigpine/contracts).
## Ways to contribute
Generated
+927 -546
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -20,8 +20,8 @@ members = [
edition = "2021"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
authors = ["gitlawb contributors"]
repository = "https://github.com/gitlawb/node"
authors = ["Twigpine contributors"]
repository = "https://github.com/Twigpine/node"
[workspace.dependencies]
# async
+1
View File
@@ -75,6 +75,7 @@ WORKDIR /data
ENV GITLAWB_REPOS_DIR=/data/repos \
GITLAWB_KEY=/data/keys/identity.pem \
GITLAWB_P2P_KEY=/data/keys/p2p.key \
GITLAWB_HOST=0.0.0.0 \
GITLAWB_PORT=7545 \
GITLAWB_P2P_PORT=7546
+1 -1
View File
@@ -169,7 +169,7 @@
END OF TERMS AND CONDITIONS
Copyright 2026 gitlawb contributors
Copyright 2026 Twigpine contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 gitlawb contributors
Copyright (c) 2026 Twigpine contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+49 -20
View File
@@ -1,10 +1,10 @@
# Gitlawb Node
# Twigpine Node
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
Gitlawb Node is the open-source node software behind the Gitlawb network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
Twigpine Node is the open-source node software behind the Twigpine network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
Gitlawb is not trying to be only "another git host." The long-term direction is:
Twigpine is not trying to be only "another git host." The long-term direction is:
```txt
Decentralized GitHub
@@ -24,17 +24,17 @@ This is a Rust workspace with six crates:
| Crate | Purpose |
|---|---|
| `gitlawb-node` | The node daemon: Axum HTTP server, git smart-HTTP, Postgres metadata, libp2p gossip, optional S3/Tigris/IPFS/Arweave/Base PoS hooks. |
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
| `gl` | The Twigpine CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Twigpine nodes. |
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
| `gitlawb-attest` | Pluggable external provenance attestations for ref-update certificates. |
| `icaptcha-client` | Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
---
## Why Gitlawb Nodes?
## Why Twigpine Nodes?
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
Most git hosting today depends on a small number of centralized platforms. Twigpine Nodes are designed for a different model:
- **Own your identity**: every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
- **Signed writes by default**: write requests use RFC 9421 HTTP Signatures instead of passwords.
@@ -47,7 +47,7 @@ Most git hosting today depends on a small number of centralized platforms. Gitla
## Current status
Gitlawb Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
Twigpine Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
Good today:
@@ -87,7 +87,7 @@ See:
The fastest path is Docker Compose. It starts a node and Postgres.
```bash
git clone https://github.com/Gitlawb/node.git
git clone https://github.com/Twigpine/node.git
cd node
cp .env.example .env
docker compose up -d
@@ -132,10 +132,10 @@ npm install -g @gitlawb/gl
brew install gitlawb/tap/gl
# curl (macOS / Linux)
curl -fsSL https://gitlawb.com/install.sh | sh
curl -fsSL https://twigpine.com/install.sh | sh
# PowerShell (Windows)
irm https://gitlawb.com/install.ps1 | iex
irm https://twigpine.com/install.ps1 | iex
```
Or build from source:
@@ -178,7 +178,7 @@ gl register --node http://localhost:7545
Create a repo:
```bash
gl repo create my-repo --description "My first Gitlawb repo" --node http://localhost:7545
gl repo create my-repo --description "My first Twigpine repo" --node http://localhost:7545
```
Use the git remote helper:
@@ -192,7 +192,7 @@ For public-network use, make sure `GITLAWB_NODE` points to the node you want. Th
### Full lifecycle against an iCaptcha-enforcing node
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
Public nodes (e.g. `node.twigpine.com`) require two things on writes:
1. **RFC 9421 HTTP Signatures**: every write is signed by your identity key. `gl`
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
@@ -205,8 +205,8 @@ Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
```bash
gl identity new # create did:key identity
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
gl register --node https://node.twigpine.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.twigpine.com # signed + auto-solves iCaptcha
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
gl doctor # preflight: identity, node, version, iCaptcha
@@ -369,6 +369,12 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
The flag is not HTTP-only. It also gates inbound gossip ref-update events on the libp2p mesh, which carry an Ed25519 payload signature rather than RFC 9421 headers because there are no HTTP headers to sign. Set to `true`, an unsigned gossip event is dropped. Set to `false`, an unsigned event is accepted with a warning during the rolling-upgrade window, and the write budget it consumes is charged to the forwarding peer rather than to the DID it claims, since an unsigned event's claimed DID is asserted and not proven. A present-but-invalid signature is refused in both modes; that is forgery, not an un-upgraded peer.
Because the flag now spans both transports, the rollout order matters in one direction: **upgrade every gossip-publishing peer to a build that signs events before you set this to `true`.** Enabling it while an old publisher is still live drops that publisher's ref-updates on arrival, and the publisher sees no error, because gossip has no response to carry one. Upgrading the HTTP peers alone is not sufficient.
There is a second precondition on gossip ingest that the flag does not control. A publisher's `node_did` must already exist in the receiving node's peers table, or the event is dropped as an unknown peer DID. This holds in both modes, signed and unsigned alike: a valid signature proves key possession, not membership. Rows reach that table over HTTP, either from `GITLAWB_BOOTSTRAP_PEERS` when this node contacts a bootstrap peer and records the DID it reports, or from a prior `POST /api/v1/peers/announce`. So a peer that only ever joined the libp2p mesh, with no HTTP announce and no bootstrap contact in either direction, will have its ref-updates dropped even with a good signature and the flag off. If gossip is silently not landing from a peer you can see on the mesh, check that its DID is in `GET /api/v1/peers` on the receiving side first.
---
## Configuration
@@ -391,8 +397,9 @@ Important node settings:
| `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. |
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
@@ -420,11 +427,33 @@ Production note: change the default Postgres password before exposing a node pub
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
### Upgrading: the PeerId rotates once
This node's libp2p identity is now a keypair generated on first start and kept
at `GITLAWB_P2P_KEY`, rather than one derived from the node DID. Every node
therefore gets a new PeerId once, on the first start after upgrading, and keeps
it from then on as long as that key file survives (put it on a persistent volume
in a container).
Two things to check before upgrading:
- Any `GITLAWB_P2P_BOOTSTRAP` multiaddr that pins a peer's old PeerId with a
`/p2p/<PeerId>` suffix stops matching once that peer upgrades. Update the
suffix, or drop it and let identify supply the current one. Addresses without
the suffix keep working untouched.
- `GITLAWB_P2P_KEY` must name a key file inside a directory, such as
`/data/keys/p2p.key` or `./keys/p2p.key`. A bare filename is refused at
startup, since the node will not keep its identity key in the working
directory.
Peers found over `GITLAWB_BOOTSTRAP_PEERS` and the embedded seed list are
unaffected, since those are HTTP URLs and carry no PeerId.
---
## Optional node staking
Gitlawb Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
Twigpine Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
PoS is disabled unless these are configured:
@@ -494,8 +523,8 @@ Build:
Output:
```txt
dist/Gitlawb Node.app
dist/Gitlawb Node.dmg
dist/Twigpine Node.app
dist/GitlawbNode-macOS.dmg
```
Features:
@@ -509,7 +538,7 @@ Features:
Unsigned local build:
```bash
xattr -cr "dist/Gitlawb Node.app"
xattr -cr "dist/Twigpine Node.app"
```
---
+4 -4
View File
@@ -2,9 +2,9 @@
## Reporting a Vulnerability
If you discover a security vulnerability in gitlawb, please **do not open a public issue**.
If you discover a security vulnerability in Twigpine, please **do not open a public issue**.
Report it privately by emailing **security@gitlawb.com** with:
Report it privately by emailing **security@twigpine.com** with:
- A description of the vulnerability
- Steps to reproduce
- Potential impact assessment
@@ -118,13 +118,13 @@ These are documented limitations of the current live release. They should be pri
## Threat Model
gitlawb is designed to be secure against:
Twigpine is designed to be secure against:
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Tampered git objects** — CIDv1 content addressing detects modification
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
gitlawb is **not yet** designed to defend against:
Twigpine is **not yet** designed to defend against:
- A compromised node operator (node operators are trusted for their own node)
- Sybil attacks on the DHT (trust score system mitigates, not eliminates)
- Timing attacks on signature verification (not constant-time compared in v0.1)
+5 -5
View File
@@ -1,11 +1,11 @@
{
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-06-30",
"peers": [
{
"name": "gitlawb",
"operator": "Gitlawb",
"operator": "Twigpine",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
@@ -13,7 +13,7 @@
},
{
"name": "gitlawb-node2",
"operator": "Gitlawb",
"operator": "Twigpine",
"did": null,
"http_url": "https://node2.gitlawb.com",
"p2p_multiaddr": null,
@@ -21,7 +21,7 @@
},
{
"name": "gitlawb-node3",
"operator": "Gitlawb",
"operator": "Twigpine",
"did": null,
"http_url": "https://node3.gitlawb.com",
"p2p_multiaddr": null,
@@ -29,7 +29,7 @@
},
{
"name": "gitlawb-manila",
"operator": "Gitlawb",
"operator": "Twigpine",
"did": null,
"http_url": "https://manila.gitlawb.com",
"p2p_multiaddr": null,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
description = "External Attestation v1: pluggable provenance attachments for Twigpine ref-update certs"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
+163 -10
View File
@@ -105,16 +105,30 @@ impl Attestation {
let bytes = canonical_signing_bytes(&self.type_, &self.payload, &self.cert_hash)?;
let vk = verifying_key_from_did_key(&self.signer)?;
self.verify_sig_with_key(&vk, &bytes)?;
Ok(vk)
}
/// The signature check itself, against a key the caller supplies.
///
/// Split out so the STRICTNESS of the check stays provable on its own.
/// `verifying_key_from_did_key` now refuses a small-order key, so a weak
/// signer can no longer reach this line through `verify_signature`. Without
/// this seam there would be nothing left that fails when `verify_strict` is
/// downgraded to the non-strict `verify`, and that regression would land
/// silently. Check order in `verify_signature` is unchanged.
fn verify_sig_with_key(&self, vk: &VerifyingKey, bytes: &[u8]) -> Result<()> {
let sig_bytes: [u8; 64] = B64U
.decode(&self.sig)
.map_err(|e| Error::Signature(format!("base64url: {e}")))?
.try_into()
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
let sig = Signature::from_bytes(&sig_bytes);
vk.verify_strict(&bytes, &sig)
vk.verify_strict(bytes, &sig)
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
Ok(vk)
Ok(())
}
/// Reparse `payload` as `P`. Errors if the type discriminator does not
@@ -181,7 +195,7 @@ fn canonical_signing_bytes(
Ok(out)
}
fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
pub(crate) fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&key.to_bytes());
@@ -202,6 +216,19 @@ fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
"did:key must use base58btc (z-prefix): {did}"
)));
}
// Refuse an oversized id before decoding. base58 decoding is quadratic in
// its input and `signer` comes off an attacker-supplied attestation, so a
// short request could otherwise buy a large decode. An ed25519 did:key
// method-id is a fixed 48 characters, so this bound is slack rather than a
// behavior change, and it only helps ahead of the decode. Mirrors the same
// cap in gitlawb-core's Did::to_verifying_key, which this crate cannot call
// (gitlawb-core is a dev-dependency here).
const MAX_METHOD_ID_LEN: usize = 64;
if method_id.len() > MAX_METHOD_ID_LEN {
return Err(Error::Did(
"did:key method-specific id too long".to_string(),
));
}
let (base, bytes) =
multibase::decode(method_id).map_err(|e| Error::Did(format!("multibase: {e}")))?;
if base != multibase::Base::Base58Btc {
@@ -217,8 +244,20 @@ fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
let key_bytes: [u8; 32] = bytes[ED25519_MULTICODEC.len()..]
.try_into()
.expect("length checked above");
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))
let key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))?;
// `from_bytes` only decompresses, so it accepts a small-order point. This
// crate parses did:key itself rather than going through gitlawb-core's
// `Did`, and it cannot go through it (gitlawb-core is a dev-dependency
// here), so the rejection is mirrored rather than inherited. Signature
// verification is already strict, so this is defense in depth against a
// future consumer of this parser that does not verify strictly.
if key.is_weak() {
return Err(Error::Did("small-order ed25519 key".to_string()));
}
Ok(key)
}
#[cfg(test)]
@@ -559,6 +598,111 @@ mod tests {
/// weak (small-order) public key satisfies the verification equation
/// but must be rejected. The identity point is such a key: with R the
/// identity and S = 0, [S]B - [k]A is the identity for any message.
/// gitlawb-attest parses did:key itself and never routes through
/// gitlawb-core's `Did`, which it cannot: gitlawb-core is a dev-dependency
/// here. So the choke-point rejection has to be mirrored in this parser or
/// this crate keeps handing out keys the rest of the system rejects.
#[test]
fn verifying_key_from_did_key_rejects_a_small_order_key() {
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&weak);
let did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let err =
verifying_key_from_did_key(&did).expect_err("a small-order did:key must not resolve");
match err {
Error::Did(msg) => assert!(
msg.contains("small-order"),
"rejection must name the small-order key, got: {msg}"
),
other => panic!("expected Error::Did, got {other:?}"),
}
}
/// base58 decoding is quadratic in its input and `signer` is attacker
/// controlled, so an oversized method-id must be refused BEFORE the decode
/// or a hostile attestation buys a large decode for a short request. The
/// assertion is on the length error specifically: a multibase error here
/// would mean the cap ran too late to matter.
#[test]
fn verifying_key_from_did_key_refuses_an_oversized_method_id_before_decoding() {
let oversized = format!("did:key:z{}", "1".repeat(65_536));
let err = verifying_key_from_did_key(&oversized)
.expect_err("an oversized method-id must be refused");
match err {
Error::Did(msg) => assert!(
msg.contains("too long"),
"must fail on the length cap, not after decoding: {msg}"
),
other => panic!("expected Error::Did, got {other:?}"),
}
}
/// Strictness regression guard. The parser now refuses a small-order key,
/// so `verify_signature` can no longer carry a weak signer down to the
/// signature check; this drives that check directly to prove it is still
/// STRICT. Downgrading `verify_strict` to `verify` makes this go red,
/// which nothing else in the suite would catch.
#[test]
fn verify_sig_with_key_is_strict_about_a_weak_key() {
let mut weak_key_bytes = [0u8; 32];
weak_key_bytes[0] = 1; // compressed identity point
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
assert!(weak_vk.is_weak());
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
// R = identity, S = 0: satisfies the non-strict verification equation
// for any message under a small-order key.
let mut forged = [0u8; 64];
forged[0] = 1;
att.sig = B64U.encode(forged);
let bytes = canonical_signing_bytes(&att.type_, &att.payload, &att.cert_hash).expect("jcs");
let err = att
.verify_sig_with_key(&weak_vk, &bytes)
.expect_err("strict verification must reject a small-order key");
assert!(
matches!(err, Error::Signature(_)),
"expected a signature error, got {err:?}"
);
}
/// Accept control for the seam: a genuine signature still verifies through
/// the same helper, so the guard above is not simply rejecting everything.
#[test]
fn verify_sig_with_key_accepts_a_genuine_signature() {
let cert_hash = sample_cert_hash();
let sk = SigningKey::generate(&mut OsRng);
let att = dummy_attestation(&sk, cert_hash);
let bytes = canonical_signing_bytes(&att.type_, &att.payload, &att.cert_hash).expect("jcs");
att.verify_sig_with_key(&sk.verifying_key(), &bytes)
.expect("a real signature must verify");
}
/// Control for the guard above: a real did:key must still resolve.
#[test]
fn verifying_key_from_did_key_still_accepts_a_real_key() {
let sk = SigningKey::from_bytes(&[3u8; 32]);
let vk = sk.verifying_key();
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&vk.to_bytes());
let did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let got = verifying_key_from_did_key(&did).expect("a real did:key must resolve");
assert_eq!(got.to_bytes(), vk.to_bytes());
}
#[test]
fn verify_rejects_weak_key_signature() {
let mut weak_key_bytes = [0u8; 32];
@@ -572,7 +716,13 @@ mod tests {
let forged_sig = B64U.encode(forged);
// Build an attestation with the weak key as signer and the forged
// signature. The weak-key check happens at verify time.
// signature. Rejection now happens at DID RESOLUTION rather than at
// verify time: `verifying_key_from_did_key` refuses a small-order key
// before `verify_strict` is reached, so the observed error is
// `Error::Did`, not `Error::Signature`. `verify_strict` remains the
// second layer for malleability cases that do not involve a weak
// public key (a small-order R under an honest key), which this fixture
// cannot construct and therefore no longer covers.
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
@@ -584,9 +734,12 @@ mod tests {
att.sig = forged_sig;
let err = att.verify_signature(cert_hash).unwrap_err();
assert!(
matches!(err, Error::Signature(_)),
"signature under a weak (small-order) public key must be rejected"
);
match err {
Error::Did(msg) => assert!(
msg.contains("small-order"),
"weak signer must be refused as a small-order key, got: {msg}"
),
other => panic!("expected Error::Did for a small-order signer, got {other:?}"),
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Pluggable provenance attachments for gitlawb ref-update certs.
//! Pluggable provenance attachments for Twigpine ref-update certs.
//!
//! A `gitlawb/ref-update/v1` cert proves who pushed and where a ref moved to.
//! It does not say how the commit was produced. This crate adds an optional
+56 -3
View File
@@ -121,7 +121,12 @@ impl Registry {
attestation: &Attestation,
expected_cert_hash: [u8; 32],
) -> Result<VerifiedAttestation> {
attestation.verify_signature(expected_cert_hash)?;
// The key that actually verified is the trust anchor for the signer a
// consumer sees. `verify_signature` recovers it from the artifact's
// `signer` field and rejects a field that does not match the signing
// key, so this is the canonical DID of the key that signed — never a
// raw artifact string that could disagree with it.
let verified_key = attestation.verify_signature(expected_cert_hash)?;
let fully = match self.by_type.get(attestation.type_.as_str()) {
Some(v) => {
@@ -138,7 +143,7 @@ impl Registry {
Ok(VerifiedAttestation {
type_: attestation.type_.clone(),
signer: attestation.signer.clone(),
signer: crate::attestation::did_key_from_verifying_key(&verified_key),
cert_hash: attestation.cert_hash.clone(),
fully_verified: fully,
})
@@ -171,7 +176,7 @@ impl Registry {
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::{Attestation, AttestationPayload};
use crate::attestation::{did_key_from_verifying_key, Attestation, AttestationPayload};
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
@@ -374,4 +379,52 @@ mod tests {
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// The signer a consumer sees must be the canonical DID of the key that
/// actually verified, never a raw artifact string that could disagree.
/// `verify_signature` derives the key from the artifact's `signer` field
/// and the parser is canonical (base58btc, exact length, ed25519
/// multicodec), so a forged field fails verification outright: the
/// must-not here is that no `VerifiedAttestation` ever carries a signer
/// that did not verify.
#[test]
fn verified_signer_is_anchored_to_the_key_that_verified() {
let signer = fresh();
let other = fresh();
assert_ne!(
signer.verifying_key().to_bytes(),
other.verifying_key().to_bytes(),
"fixture precondition: distinct signing keys"
);
let cert_hash = sample_hash();
let reg = Registry::new();
// Control: an honest artifact reports the signer's canonical DID, and
// it agrees with the artifact field.
let att = signed_demo(&signer, cert_hash, "ok");
let v = reg.verify(&att, cert_hash).unwrap();
let expected = did_key_from_verifying_key(&signer.verifying_key());
assert_eq!(v.signer, expected, "signer must be the verified key's DID");
assert_eq!(v.signer, att.signer, "honest artifact: field and key agree");
// Must-not, both directions: a signer field naming a DIFFERENT key
// than the one that signed must fail verification, so the artifact
// string can never redirect the reported signer.
let mut forged = signed_demo(&signer, cert_hash, "ok");
forged.signer = did_key_from_verifying_key(&other.verifying_key());
let err = reg.verify(&forged, cert_hash).unwrap_err();
assert!(
matches!(err, Error::Signature(_)),
"a signer field that does not match the signing key must fail, got {err:?}"
);
let mut forged_rev = signed_demo(&other, cert_hash, "ok");
forged_rev.signer = did_key_from_verifying_key(&signer.verifying_key());
let err_rev = reg.verify(&forged_rev, cert_hash).unwrap_err();
assert!(
matches!(err_rev, Error::Signature(_)),
"mismatched signer field must fail verification, got {err_rev:?}"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
+56 -5
View File
@@ -1,11 +1,11 @@
//! Decentralized Identifier (DID) types for the gitlawb network.
//! Decentralized Identifier (DID) types for the Twigpine network.
//!
//! Supported methods:
//! - `did:key` — ephemeral, derived directly from an Ed25519 public key
//! - `did:web` — anchored to a domain
//! - `did:gitlawb` — native, anchored to the libp2p DHT
//!
//! The canonical DID for a gitlawb actor is `did:key` during bootstrap
//! The canonical DID for a Twigpine actor is `did:key` during bootstrap
//! and migrates to `did:gitlawb` once DHT anchoring is live.
use ed25519_dalek::VerifyingKey;
@@ -71,7 +71,8 @@ impl Did {
/// Resolve the Ed25519 verifying key from a `did:key`.
///
/// Returns `Err` if the DID is not a `did:key` or the key bytes are invalid.
/// Returns `Err` if the DID is not a `did:key`, the key bytes are malformed,
/// or the key is a small-order (weak) point.
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
if !self.is_did_key() {
return Err(Error::InvalidDid(format!(
@@ -106,7 +107,25 @@ impl Did {
.try_into()
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))
let key =
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))?;
// `from_bytes` only decompresses, so it accepts a small-order point.
// Such a key satisfies the verification equation for any message, and
// every X25519 shared secret derived from it is the all-zero key: the
// order-1 and order-2 points convert to the all-zero Montgomery u
// directly, and the order-4 and order-8 points are annihilated by the
// scalar clamping instead. `is_weak` covers the whole torsion set.
// Mixed-order points are correctly NOT rejected, since clamping clears
// the cofactor component. Rejecting
// at resolution is what makes this the choke point: every consumer that
// resolves a DID through here inherits the rejection, and a recipient
// that cannot resolve already fails closed downstream.
if key.is_weak() {
return Err(Error::InvalidDid("small-order ed25519 key".to_string()));
}
Ok(key)
}
/// Return the full DID string as a `&str`.
@@ -152,7 +171,7 @@ impl From<Did> for String {
}
}
/// A DID Document for a gitlawb actor.
/// A DID Document for a Twigpine actor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidDocument {
#[serde(rename = "@context")]
@@ -369,4 +388,36 @@ mod tests {
);
did.to_verifying_key().expect("a real did:key must resolve");
}
/// A `did:key` can encode a small-order (weak) Ed25519 point. Such a key
/// satisfies the verification equation for any message and, converted to
/// Montgomery form for X25519, yields the all-zero shared secret, so an
/// envelope sealed to it is decryptable by anyone. Resolution is the choke
/// point every `Did`-based consumer shares, so it fails closed here.
#[test]
fn to_verifying_key_rejects_a_small_order_key() {
// The compressed identity point, the canonical small-order key.
let mut weak = [0u8; 32];
weak[0] = 1;
let mut prefixed = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
prefixed.extend_from_slice(ED25519_MULTICODEC);
prefixed.extend_from_slice(&weak);
let did: Did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &prefixed)
)
.parse()
.expect("a small-order did:key is still well-formed");
let err = did
.to_verifying_key()
.expect_err("a small-order did:key must not resolve");
match err {
Error::InvalidDid(msg) => assert!(
msg.contains("small-order"),
"rejection must name the small-order key, got: {msg}"
),
other => panic!("expected Error::InvalidDid, got {other:?}"),
}
}
}
+412 -2
View File
@@ -11,6 +11,22 @@ use zeroize::Zeroizing;
/// X25519 public key (Montgomery u) for an Ed25519 verifying key.
fn x25519_public(vk: &VerifyingKey) -> Result<[u8; 32]> {
use curve25519_dalek::edwards::CompressedEdwardsY;
// Every X25519 shared secret derived from a small-order point is the
// all-zero key: order-1 and order-2 convert to the all-zero Montgomery u
// directly, order-4 and order-8 are annihilated by the scalar clamping in
// x25519_secret_from_seed. Either way the per-recipient wrap could be
// rebuilt by anyone. Resolution already refuses such a key (see
// Did::to_verifying_key); this guard covers the SEAL side on its own terms
// for a caller that obtained the key some other way. The open side
// (open_blob's attacker-supplied `eph`) skips any entry whose exchange with
// this reader yields the all-zero shared secret
// (`yields_all_zero_shared_secret`), regardless of whether the bytes decode
// to an Edwards point.
if vk.is_weak() {
return Err(anyhow::anyhow!("verifying key is a small-order point"));
}
let edwards = CompressedEdwardsY::from_slice(vk.as_bytes())
.ok()
.and_then(|c| c.decompress())
@@ -18,6 +34,26 @@ fn x25519_public(vk: &VerifyingKey) -> Result<[u8; 32]> {
Ok(edwards.to_montgomery().to_bytes())
}
/// True when the X25519 exchange between an attacker-supplied `u` and this
/// reader's scalar yields the all-zero shared secret.
///
/// Asked of the RESULT, not of the encoding, and that distinction is the whole
/// guard. The previous version decompressed `u` to an Edwards point and asked
/// `is_small_order`, which reports "safe" for any low-order encoding that does
/// not decompress at all. Measured across the seven standard low-order
/// encodings, six were caught and `u = p - 1` was not: it fails to decompress,
/// so the check returned false, and the exchange is still the all-zero secret.
/// An entry built on it unwraps for every reader.
///
/// Enumerating encodings cannot be exhaustive, because the attack depends on the
/// shared secret rather than on the bytes that produced it, and twist and
/// non-canonical inputs keep arriving. Asking the result needs no list.
fn yields_all_zero_shared_secret(u: &[u8; 32], scalar: &[u8; 32]) -> bool {
use curve25519_dalek::montgomery::MontgomeryPoint;
MontgomeryPoint(*u).mul_clamped(*scalar).to_bytes() == [0u8; 32]
}
/// X25519 secret scalar for an Ed25519 seed (SHA-512 of seed, lower 32, clamped).
/// Returns the scalar wrapped in `Zeroizing`, and scrubs the intermediate
/// SHA-512 digest, so no copy of this secret material lingers in freed memory.
@@ -128,7 +164,10 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
.context("decode header")?;
let body = &envelope[p + hlen..];
let my_x = XSecret::from(*x25519_secret_from_seed(&keypair.to_seed()));
// The raw scalar is kept alongside the box secret so the exchange can be
// tested BEFORE a box is built from an attacker-supplied ephemeral.
let my_x_scalar = x25519_secret_from_seed(&keypair.to_seed());
let my_x = XSecret::from(*my_x_scalar);
// Identities are blinded: no entry says which recipient it belongs to, so
// try each one. The ChaChaBox AEAD tag authenticates, so exactly the
@@ -140,7 +179,12 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
.ok()
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
{
Some(b) => XPublic::from(b),
// An ephemeral whose exchange with this reader is the all-zero
// shared secret would unwrap for anyone, so the entry is skipped.
// Tested on the exchange itself: an encoding-shaped check misses
// every low-order input that does not decompress to Edwards.
Some(b) if !yields_all_zero_shared_secret(&b, &my_x_scalar) => XPublic::from(b),
Some(_) => continue,
None => continue,
};
// from_slice panics on a wrong length, and the envelope is attacker
@@ -293,4 +337,370 @@ mod tests {
header["nonce"] = bad_nonce;
assert!(open_blob(&reframe(&header), &reader).is_err());
}
/// Every standard low-order X25519 encoding must be detected by
/// `yields_all_zero_shared_secret`, including encodings that do not
/// decompress to an Edwards point (notably `u = p - 1`). The open side
/// rejects on the exchange result, not on decode shape, because an
/// encoding-shaped check misses non-decompressing low-order inputs while
/// still yielding the all-zero shared secret.
#[test]
fn all_zero_shared_secret_is_detected_for_every_standard_low_order_encoding() {
// The seven standard X25519 low-order encodings, driven as a set rather
// than as the two that happen to decompress. The encoding-shaped check
// this replaced reported `p - 1` as safe, and its exchange is all-zero.
let mut one = [0u8; 32];
one[0] = 1;
let hex = |h: &str| -> [u8; 32] {
let mut out = [0u8; 32];
for i in 0..32 {
out[i] = u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).unwrap();
}
out
};
let vectors = [
("u=0", [0u8; 32]),
("u=1", one),
(
"order8-a",
hex("e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"),
),
(
"order8-b",
hex("5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"),
),
(
"p-1",
hex("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
),
(
"p",
hex("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
),
(
"p+1",
hex("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
),
];
let kp = Keypair::generate();
let scalar = x25519_secret_from_seed(&kp.to_seed());
for (name, u) in vectors {
assert!(
yields_all_zero_shared_secret(&u, &scalar),
"{name} is a low-order encoding and must be detected"
);
}
// And the other direction, or a guard that returned true always would
// pass everything above while making the envelope unopenable.
let real_u = x25519_public(&kp.verifying_key()).unwrap();
assert!(
!yields_all_zero_shared_secret(&real_u, &scalar),
"a real key must not be treated as low-order"
);
}
/// The open side must skip an entry whose header-supplied `eph` is a
/// low-order point: such an entry forces the all-zero shared secret and
/// would unwrap for any reader. A poisoned entry among honest ones must
/// not DoS the envelope — the honest entry still opens.
#[test]
fn open_blob_skips_a_low_order_ephemeral_and_still_opens_honest_entry() {
use curve25519_dalek::montgomery::MontgomeryPoint;
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
// Split the envelope framing into header JSON and body.
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header_bytes = &env[p..p + hlen];
let body = &env[p + hlen..];
// A low-order Montgomery u (u = 0) encoded as the entry's eph.
let low_order_u = MontgomeryPoint([0u8; 32]).to_bytes();
let poisoned = serde_json::json!({
"eph": B64.encode(low_order_u),
"nonce": B64.encode([0u8; 24]),
"wrap": B64.encode([0u8; 32]),
});
let reframe = |header: &serde_json::Value| -> Vec<u8> {
let hj = serde_json::to_vec(header).unwrap();
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.extend_from_slice(&(hj.len() as u32).to_le_bytes());
out.extend_from_slice(&hj);
out.extend_from_slice(body);
out
};
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
// Prepend the poisoned entry. The reader's honest entry must still
// unwrap, so the envelope opens despite the low-order entry.
let recipients = header["recipients"].as_array_mut().unwrap();
recipients.insert(0, poisoned);
assert_eq!(
open_blob(&reframe(&header), &reader).unwrap(),
b"private blob contents",
"the honest entry must still open despite a low-order eph entry"
);
}
/// Must-not for the open-side guard: an envelope whose ONLY entry is built
/// on a low-order ephemeral must not open for any reader. Without the
/// guard the all-zero shared secret unwraps for everyone, so the attacker
/// controls the plaintext and the envelope's confidentiality is void.
#[test]
fn open_blob_rejects_a_poisoned_only_envelope() {
use chacha20poly1305::{aead::Aead, KeyInit, XChaCha20Poly1305, XNonce};
use crypto_box::aead::AeadCore;
use curve25519_dalek::montgomery::MontgomeryPoint;
// Build a poisoned envelope from scratch: one recipient entry whose
// eph is the low-order u = 0. The wrap is the encryption of a content
// key under the all-zero shared secret, so without the guard any
// reader's ChaChaBox against u = 0 decrypts it.
let mut content_key: [u8; 32] = Default::default();
OsRng.fill_bytes(&mut content_key);
let attacker_x = XSecret::generate(&mut OsRng);
let zero_box = ChaChaBox::new(&XPublic::from(<[u8; 32]>::default()), &attacker_x);
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let wrap = zero_box.encrypt(&nonce, &content_key[..]).unwrap();
let mut body_nonce: [u8; 24] = Default::default();
OsRng.fill_bytes(&mut body_nonce);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
let body = body_cipher
.encrypt(
XNonce::from_slice(&body_nonce),
b"attacker-chosen plaintext".as_slice(),
)
.unwrap();
let header = serde_json::json!({
"alg": "xchacha20poly1305",
"nonce": B64.encode(body_nonce),
"recipients": [{
"eph": B64.encode(MontgomeryPoint([0u8; 32]).to_bytes()),
"nonce": B64.encode(nonce),
"wrap": B64.encode(wrap),
}],
});
let header_json = serde_json::to_vec(&header).unwrap();
let mut env = Vec::new();
env.extend_from_slice(MAGIC);
env.push(VERSION);
env.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
env.extend_from_slice(&header_json);
env.extend_from_slice(&body);
let reader = Keypair::generate();
let err = open_blob(&env, &reader).unwrap_err();
assert!(
err.to_string().contains("not a recipient"),
"a poisoned-only envelope must not open for anyone, got: {err}"
);
}
/// The same must-not, driven with the low-order encoding that does NOT map
/// to an Edwards point.
///
/// This is the case an encoding-shaped guard cannot see. `u = p - 1` is a
/// legal X25519 input whose exchange is the all-zero secret for any scalar,
/// and `MontgomeryPoint::to_edwards` returns None for it, so a check written
/// as "decompress, then ask is_small_order" reports it as safe. Measured
/// across the seven standard low-order encodings: six are caught that way
/// and this one is not.
///
/// Found by a cross-family review of the head that first added the guard,
/// and the comment beside that guard asserted this case was already handled
/// by a decode path that does not exist. That is why the fix rejects on the
/// RESULT of the exchange rather than on a list of encodings: the result is
/// what the attack actually depends on, and it needs no enumeration to be
/// exhaustive.
#[test]
fn open_blob_rejects_a_non_edwards_low_order_ephemeral() {
// No blanket `use ...::Aead` here. Two aead versions are reachable, and a
// blanket import silently resolved the wrap through the one open_blob does
// NOT decrypt with, producing a ciphertext that could never open. The test
// then passed with the guard, without it, and with the original pre-fix
// guard alike, proving nothing. Every call below names its trait.
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
use crypto_box::aead::AeadCore;
// u = p - 1 = 2^255 - 20, little endian.
let mut eph = [0xffu8; 32];
eph[0] = 0xec;
eph[31] = 0x7f;
let mut content_key: [u8; 32] = Default::default();
OsRng.fill_bytes(&mut content_key);
let attacker_x = XSecret::generate(&mut OsRng);
let zero_box = ChaChaBox::new(&XPublic::from(<[u8; 32]>::default()), &attacker_x);
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let wrap = crypto_box::aead::Aead::encrypt(&zero_box, &nonce, &content_key[..]).unwrap();
let mut body_nonce: [u8; 24] = Default::default();
OsRng.fill_bytes(&mut body_nonce);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
let body = chacha20poly1305::aead::Aead::encrypt(
&body_cipher,
XNonce::from_slice(&body_nonce),
b"attacker-chosen plaintext".as_slice(),
)
.unwrap();
let header = serde_json::json!({
"alg": "xchacha20poly1305",
"nonce": B64.encode(body_nonce),
"recipients": [{
"eph": B64.encode(eph),
"nonce": B64.encode(nonce),
"wrap": B64.encode(wrap),
}],
});
let header_json = serde_json::to_vec(&header).unwrap();
let mut env = Vec::new();
env.extend_from_slice(MAGIC);
env.push(VERSION);
env.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
env.extend_from_slice(&header_json);
env.extend_from_slice(&body);
let reader = Keypair::generate();
let err = open_blob(&env, &reader).unwrap_err();
assert!(
err.to_string().contains("not a recipient"),
"an envelope whose only entry uses a non-Edwards low-order eph must not \
open for anyone, got: {err}"
);
}
/// The compressed identity point: a well-formed Ed25519 encoding that is
/// small-order, so every shared secret derived from it is all-zero.
fn weak_verifying_key() -> VerifyingKey {
let mut weak = [0u8; 32];
weak[0] = 1;
let vk = VerifyingKey::from_bytes(&weak).expect("identity point decompresses");
assert!(vk.is_weak(), "fixture precondition: key is small-order");
vk
}
/// A small-order recipient key converts to Montgomery u = 0, and X25519
/// against u = 0 is the all-zero shared secret for ANY scalar, so the
/// wrapping box is reconstructable with no secret at all. Reject at the
/// primitive so the seal side is safe regardless of how the caller
/// obtained the key.
#[test]
fn x25519_public_rejects_a_small_order_key() {
let weak_vk = weak_verifying_key();
assert!(
x25519_public(&weak_vk).is_err(),
"a small-order key must not yield an x25519 public key"
);
}
/// Control: a legitimate key still converts, and to a non-zero u.
#[test]
fn x25519_public_still_accepts_a_real_key() {
let kp = Keypair::generate();
let u = x25519_public(&kp.verifying_key()).expect("a real key must convert");
assert_ne!(u, [0u8; 32], "a real key must not map to the zero point");
}
/// The guard has to propagate: no envelope may be produced for a weak
/// recipient even when the DID choke point is bypassed entirely.
#[test]
fn seal_blob_refuses_a_small_order_recipient() {
let weak_vk = weak_verifying_key();
assert!(
seal_blob(b"withheld", &[weak_vk]).is_err(),
"sealing to a weak recipient must fail"
);
// And a mixed set must fail too: one weak recipient exposes the shared
// content key, so a partial envelope is not an acceptable outcome.
let honest = Keypair::generate();
assert!(
seal_blob(b"withheld", &[honest.verifying_key(), weak_vk]).is_err(),
"a mixed honest+weak recipient set must fail closed, not seal partially"
);
}
/// Control for the two above: the ordinary seal/open round trip is intact.
#[test]
fn legit_seal_open_round_trip_still_works() {
let reader = Keypair::generate();
let env = seal_blob(b"withheld blob", &[reader.verifying_key()]).expect("seal");
assert_eq!(open_blob(&env, &reader).expect("open"), b"withheld blob");
}
/// The defect this fix exists to close, kept as an executable regression
/// rather than an assertion about absence. It runs the real attack: craft a
/// small-order did:key, get it into a recipient set alongside an honest
/// reader, then rebuild the wrapping box from the all-zero shared secret
/// that a small-order recipient forces. If any guard regresses, this does
/// not merely fail, it fails printing the plaintext it recovered.
#[test]
fn attacker_cannot_recover_plaintext_via_a_weak_recipient() {
use crate::did::Did;
use std::str::FromStr;
let weak_vk = weak_verifying_key();
// Layer 1: the attacker's did:key string must not resolve at all.
let weak_did = Did::from_verifying_key(&weak_vk).to_string();
assert!(
Did::from_str(&weak_did)
.expect("still a well-formed did:key")
.to_verifying_key()
.is_err(),
"a small-order did:key must not resolve"
);
// Layer 2: even handed the key directly, sealing must refuse. One weak
// recipient would expose the single shared content key, so the honest
// reader's blob would be readable by anyone.
let honest = Keypair::generate();
let secret_plaintext = b"WITHHELD BLOB CONTENTS";
let envelope = match seal_blob(secret_plaintext, &[honest.verifying_key(), weak_vk]) {
Err(_) => return, // no envelope exists, nothing to attack
Ok(env) => env,
};
// Only reachable if a guard regressed. Run the attack and report what
// it got, so the failure names the actual exposure.
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(envelope[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header: serde_json::Value = serde_json::from_slice(&envelope[p..p + hlen]).unwrap();
let body = &envelope[p + hlen..];
let body_nonce = B64.decode(header["nonce"].as_str().unwrap()).unwrap();
// X25519 against u = 0 is the all-zero shared secret for any scalar, so
// the sealer's box is reconstructable with a key the attacker picks.
let zero_box = ChaChaBox::new(
&XPublic::from(<[u8; 32]>::default()),
&XSecret::generate(&mut OsRng),
);
for r in header["recipients"].as_array().unwrap() {
let n = B64.decode(r["nonce"].as_str().unwrap()).unwrap();
let w = B64.decode(r["wrap"].as_str().unwrap()).unwrap();
if let Ok(content_key) = zero_box.decrypt(n.as_slice().into(), w.as_slice()) {
let cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
let recovered = cipher
.decrypt(XNonce::from_slice(&body_nonce), body)
.expect("body decrypts once the content key is out");
panic!(
"REGRESSION: attacker recovered plaintext with no private key: {:?}",
String::from_utf8_lossy(&recovered)
);
}
}
panic!("an envelope was sealed to a small-order recipient; the seal guard regressed");
}
}
+235 -12
View File
@@ -1,6 +1,6 @@
//! HTTP Signatures (RFC 9421) for gitlawb.
//!
//! Every write request to a gitlawb node must be signed by the actor's
//! Every write request to a Twigpine node must be signed by the actor's
//! Ed25519 private key. The signature covers:
//! - `@method` — HTTP method (uppercase)
//! - `@path` — request path and query
@@ -14,13 +14,13 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use chrono::Utc;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// The component identifiers covered by every gitlawb signature.
/// The component identifiers covered by every Twigpine signature.
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
/// The three headers produced by RFC 9421 signing.
@@ -67,11 +67,20 @@ impl HttpSignature {
let components_str = &rest[open + 1..close];
let params_str = &rest[close + 1..]; // starts with ';'
// "\"@method\" \"@path\" \"content-digest\"" → ["@method", "@path", "content-digest"]
let components: Vec<String> = components_str
.split_whitespace()
.map(|s| s.trim_matches('"').to_string())
.collect();
// RFC 9421 §2.1: a component identifier appears at most once. Gitlawb
// only supports bare names; parameters are rejected so reordered
// equivalent forms cannot bypass duplicate detection as distinct strings.
let mut components = Vec::new();
let mut seen = HashSet::new();
for token in components_str.split_whitespace() {
let name = parse_covered_component(token)?;
if !seen.insert(name.clone()) {
return Err(Error::HttpSignature(format!(
"duplicate covered component '{name}' in Signature-Input"
)));
}
components.push(name);
}
let params = parse_params(params_str)?;
@@ -113,15 +122,48 @@ impl HttpSignature {
})
}
/// Reject if the `created` timestamp is more than 5 minutes from now.
/// Reject a `created` timestamp outside the acceptance window, which is
/// ASYMMETRIC: up to 5 minutes into the past, but only 60 seconds into the
/// future.
///
/// The two directions are two comparisons rather than one distance, because
/// they are not the same event. A signature that arrives late is ordinary:
/// clocks run slow, requests queue, and networks are not instant, so the
/// past side keeps the full 300-second allowance. A signature stamped ahead
/// of this node's clock is not ordinary, and folding both sides into an
/// `abs()` let a signer extend its own validity: signed at T with
/// `created = T + 300`, it stayed acceptable until roughly T + 600, so the
/// effective window was double the one this function advertises. The
/// forward tolerance exists only to absorb honest drift between two hosts,
/// which is what 60 seconds buys; it is the same allowance the gossip
/// path's freshness check uses for the same reason.
pub fn check_created(&self) -> Result<()> {
const MAX_AGE_SECS: i64 = 300;
const MAX_FUTURE_SECS: i64 = 60;
let now = Utc::now().timestamp();
let skew = (now - self.created).abs();
if skew > 300 {
// Saturating, not plain subtraction. `created` is parsed as an
// unrestricted i64 straight from the Signature-Input header, so a
// sender picks it: at i64::MIN the past-side subtraction overflows,
// which panics a debug build and wraps a release one into an absurd
// value that can read as acceptable. Saturating gives the answer the
// check wants at both extremes, since a timestamp that far away is
// refused by whichever side it saturates toward.
let age = now.saturating_sub(self.created);
if age > MAX_AGE_SECS {
return Err(Error::HttpSignature(format!(
"clock skew too large: {skew}s (max 300s)"
"clock skew too large: created {age}s in the past (max {MAX_AGE_SECS}s)"
)));
}
let ahead = self.created.saturating_sub(now);
if ahead > MAX_FUTURE_SECS {
return Err(Error::HttpSignature(format!(
"clock skew too large: created {ahead}s in the future (max {MAX_FUTURE_SECS}s)"
)));
}
Ok(())
}
@@ -203,6 +245,33 @@ pub fn compute_content_digest(body: &[u8]) -> String {
format!("sha-256=:{}:", STANDARD.encode(hasher.finalize()))
}
/// Parse one covered-component token from Signature-Input's parenthesized list.
///
/// Gitlawb only supports bare component names (`"@method"`, `"@path"`,
/// `"content-digest"`). RFC 9421 permits parameters on identifiers; they are
/// rejected rather than accepted in a form `missing_components()` and
/// `build_signing_string()` cannot handle consistently.
fn parse_covered_component(token: &str) -> Result<String> {
let token = token.trim();
let open = token
.strip_prefix('"')
.ok_or_else(|| Error::HttpSignature("component must be a quoted string".into()))?;
let close = open
.find('"')
.ok_or_else(|| Error::HttpSignature("unterminated component string".into()))?;
let name = &open[..close];
if name.is_empty() {
return Err(Error::HttpSignature("empty component name".into()));
}
let trailing = open[close + 1..].trim();
if !trailing.is_empty() {
return Err(Error::HttpSignature(format!(
"component parameters are not supported on '{name}'"
)));
}
Ok(name.to_string())
}
/// Parse `;key="value";key2=value` parameter string into a map.
fn parse_params(s: &str) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
@@ -272,6 +341,91 @@ mod tests {
assert!(missing.contains(&"content-digest"));
}
/// RFC 9421 §2.1: a component identifier must not appear twice in the
/// covered list. Repeating one is not a way to say anything, but it does
/// repeat a line in the signing string, so the size of what a verifier
/// builds (and what a node persists alongside a claim) is set by how many
/// times the caller chose to write the same name.
#[test]
fn parse_rejects_component_parameters() {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest";sf);keyid="{did}";alg="ed25519";created=1000"#
);
let err = HttpSignature::parse(
&sig_input,
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
)
.expect_err("parameterized components must be refused");
assert!(
err.to_string().contains("component parameters"),
"got: {err}"
);
}
#[test]
fn parse_rejects_reordered_equivalent_component_parameters() {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("content-digest";sf;tr "content-digest";tr;sf "@method" "@path");keyid="{did}";alg="ed25519";created=1000"#
);
let err = HttpSignature::parse(
&sig_input,
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
)
.expect_err("reordered equivalent parameters must not bypass as distinct components");
assert!(
err.to_string().contains("component parameters"),
"got: {err}"
);
}
#[test]
fn parse_many_distinct_components_completes_quickly() {
use std::time::{Duration, Instant};
let kp = Keypair::generate();
let did = kp.did();
let names: Vec<String> = (0..20_000).map(|i| format!("\"c{i}\"")).collect();
let inner = names.join(" ");
let sig_input = format!(r#"sig1=({inner});keyid="{did}";alg="ed25519";created=1000"#);
let start = Instant::now();
let result = HttpSignature::parse(
&sig_input,
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"duplicate tracking must stay linear, took {elapsed:?}"
);
assert!(result.is_ok(), "distinct names should parse: {:?}", result);
}
#[test]
fn parse_rejects_duplicate_components() {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("@method" "@path" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
);
let err = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:")
.expect_err("a repeated component must be refused");
assert!(
err.to_string().contains("duplicate"),
"the error must name the duplication, got: {err}"
);
// The control: the same list without the repeat still parses.
let ok = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
);
HttpSignature::parse(&ok, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:")
.expect("a distinct component list must still parse");
}
#[test]
fn verify_signature_end_to_end() {
use crate::identity::verify;
@@ -335,6 +489,75 @@ mod tests {
assert!(sig.check_created().is_err());
}
/// Build a parseable signature whose `created` is `offset` seconds from now
/// (negative for the past), so a test can pin one side of the window.
fn sig_created_at_offset(offset: i64) -> HttpSignature {
let kp = Keypair::generate();
let did = kp.did();
let created = Utc::now().timestamp() + offset;
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
}
/// Build a signature at an ABSOLUTE `created`, so a test can drive the
/// extremes an offset from now cannot reach.
fn sig_created_absolute(created: i64) -> HttpSignature {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
}
/// `created` is parsed as an unrestricted i64 from a header the sender
/// writes, so the window check has to survive both ends of the type. Before
/// the saturating form this panicked with "attempt to subtract with
/// overflow" on the past side, which is a debug-build crash any client
/// could trigger by sending one header value.
#[test]
fn extreme_created_values_are_refused_rather_than_overflowing() {
for created in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
let err = sig_created_absolute(created)
.check_created()
.expect_err("an extreme created must be refused, not accepted");
let msg = err.to_string();
assert!(
msg.contains("past") || msg.contains("future"),
"created={created} must be refused by a named direction, got: {msg}"
);
}
}
#[test]
fn created_301s_in_past_rejected() {
let err = sig_created_at_offset(-301).check_created().unwrap_err();
assert!(err.to_string().contains("past"), "{err}");
}
#[test]
fn created_299s_in_past_accepted() {
assert!(sig_created_at_offset(-299).check_created().is_ok());
}
#[test]
fn created_61s_in_future_rejected() {
let err = sig_created_at_offset(61).check_created().unwrap_err();
assert!(err.to_string().contains("future"), "{err}");
}
#[test]
fn created_30s_in_future_accepted() {
assert!(sig_created_at_offset(30).check_created().is_ok());
}
#[test]
fn created_exactly_now_accepted() {
assert!(sig_created_at_offset(0).check_created().is_ok());
}
#[test]
fn fresh_signature_passes_clock_skew() {
let kp = Keypair::generate();
+2 -2
View File
@@ -1,6 +1,6 @@
//! Keypair generation, storage, and signing.
//!
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
//! A Twigpine identity is an Ed25519 keypair. The public key encodes into a
//! `did:key` DID. The private key is stored as PKCS#8 PEM on disk.
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
@@ -11,7 +11,7 @@ use zeroize::Zeroizing;
use crate::did::Did;
use crate::{Error, Result};
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
/// An Ed25519 keypair that is the root identity for a Twigpine actor.
#[derive(Clone)]
pub struct Keypair {
signing_key: SigningKey,
+3 -3
View File
@@ -1,4 +1,4 @@
//! Redirect policy shared by every gitlawb HTTP client that signs its requests.
//! Redirect policy shared by every Twigpine HTTP client that signs its requests.
//!
//! Both `gl` (async) and `git-remote-gitlawb` (blocking) attach RFC 9421
//! `Signature` and `Signature-Input` headers, and reqwest strips only
@@ -50,7 +50,7 @@ pub const MAX_REDIRECTS: usize = 10;
/// signed and unsigned callers alike, for the same reason the predicate is shared:
/// two rules would drift.
///
/// The clause pins `@path`, and only `@path`. A gitlawb signature also covers
/// The clause pins `@path`, and only `@path`. A Twigpine signature also covers
/// `@method` and `content-digest`, and both of those are still open on a followed
/// hop: on a 301, 302 or 303, reqwest 0.12.28 delegates to tower-http's
/// `FollowRedirect`, which rewrites a POST to a GET and empties the body
@@ -188,7 +188,7 @@ mod tests {
"the node's name pushed into the fragment: the host is the attacker's",
),
// The request-target clause, both directions. `@path` is the only thing a
// gitlawb signature binds the request to, so a hop that rewrites it hands
// Twigpine signature binds the request to, so a hop that rewrites it hands
// the node a signature over a target it never received.
(
"https://node.example/a?x=1",
+2 -2
View File
@@ -1,7 +1,7 @@
//! UCAN (User Controlled Authorization Networks) — capability token types.
//!
//! UCANs let a DID delegate specific capabilities to another DID,
//! with optional expiry and revocation. gitlawb uses UCANs for:
//! with optional expiry and revocation. Twigpine uses UCANs for:
//! - Delegating push access to a branch to a CI agent
//! - Granting a reviewer the ability to approve PRs
//! - Bootstrap tokens issued at registration
@@ -57,7 +57,7 @@ impl Capability {
}
}
/// Well-known gitlawb capability strings.
/// Well-known Twigpine capability strings.
pub mod caps {
pub const GIT_PUSH: &str = "git/push";
pub const GIT_FETCH: &str = "git/fetch";
+11 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
@@ -33,6 +33,7 @@ sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chron
clap = { version = "4", features = ["derive", "env"] }
bytes = "1"
libc = "0.2"
zeroize = "1"
cid = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
@@ -44,14 +45,14 @@ dirs-next = "2"
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
unicode-normalization = "0.1"
reqwest = { workspace = true }
libp2p-core = "0.43"
libp2p-gossipsub = "0.49.4"
libp2p-identify = "0.47"
libp2p-identity = { version = "0.2", features = ["ed25519", "peerid", "rand"] }
libp2p-kad = "0.48"
libp2p-quic = { version = "0.13", features = ["tokio"] }
libp2p-swarm = { version = "0.47", features = ["macros", "tokio"] }
multiaddr = "0.18"
libp2p-core = "0.44"
libp2p-gossipsub = "0.50.1"
libp2p-identify = "0.48"
libp2p-identity = { version = "0.3", features = ["ed25519", "peerid", "rand"] }
libp2p-kad = "0.49"
libp2p-quic = { version = "0.14", features = ["tokio"] }
libp2p-swarm = { version = "0.48", features = ["macros", "tokio"] }
multiaddr = "0.19"
futures = "0.3"
aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-config = { version = "1", features = ["behavior-version-latest"] }
@@ -72,7 +73,7 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
+30 -10
View File
@@ -2520,7 +2520,10 @@ mod tests {
"/ipfs/{cid}",
axum::routing::get(crate::api::ipfs::get_by_cid),
)
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::optional_signature,
))
.with_state(state)
}
@@ -3758,9 +3761,13 @@ mod tests {
// deterministically.
let endpoint = crate::test_support::silent_http_endpoint().await;
let tigris =
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
.await;
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir,
Some(tigris),
pool,
std::time::Duration::from_secs(300),
);
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
let mut cfg = (*state.config).clone();
cfg.git_acquire_timeout_secs = 1;
@@ -3831,9 +3838,13 @@ mod tests {
// (endpoint-pinned test client, no AWS_* env reads).
let endpoint = crate::test_support::silent_http_endpoint().await;
let tigris =
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
.await;
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir,
Some(tigris),
pool,
std::time::Duration::from_secs(300),
);
let mut cfg = (*state.config).clone();
cfg.git_acquire_timeout_secs = 1;
state.config = Arc::new(cfg);
@@ -7956,9 +7967,18 @@ mod tests {
// (endpoint-pinned test client, no AWS_* env reads).
let endpoint = crate::test_support::silent_http_endpoint().await;
let tigris =
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
.await;
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir,
Some(tigris),
pool,
std::time::Duration::from_secs(300),
);
state
.db
.upsert_mirror_repo("z6f3budget", "ghost", "/unused-ghost", None, false)
.await
.unwrap();
let mut cfg = (*state.config).clone();
cfg.ipfs_request_budget_secs = 1;
cfg.git_acquire_timeout_secs = 2;
File diff suppressed because it is too large. Load diff
+19
View File
@@ -92,6 +92,25 @@ pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()
}
}
/// Validate a branch name using the configured git binary (storage boundaries).
pub(crate) fn validate_git_ref_with_git(
git_bin: &str,
name: &str,
) -> std::result::Result<(), crate::git::store::GitRefValidationError> {
crate::git::store::validate_git_ref_with_git(git_bin, name)
}
/// Map ref-validation failures to the correct HTTP surface: malformed caller
/// input stays 400; an inability to run git stays on the git/server-error path.
pub(crate) fn map_git_ref_validation_error(
err: crate::git::store::GitRefValidationError,
) -> AppError {
match err {
crate::git::store::GitRefValidationError::Invalid(msg) => AppError::BadRequest(msg),
crate::git::store::GitRefValidationError::GitUnavailable(msg) => AppError::Git(msg),
}
}
#[cfg(test)]
mod did_tests {
use super::did_matches;
+60 -8
View File
@@ -118,11 +118,11 @@ pub fn is_public_http_url(raw: &str) -> bool {
Some(h) => h.to_ascii_lowercase(),
None => return false,
};
// Drop a single trailing dot (FQDN root): `localhost.` resolves the same as
// Drop trailing dots (FQDN root): `localhost.` resolves the same as
// `localhost`, so normalize before the suffix/equality checks below.
if let Some(stripped) = host.strip_suffix('.') {
host = stripped.to_string();
}
// Strip to fixation rather than once, or `localhost..` reduces to
// `localhost.` and matches neither the equality nor the suffix check.
host.truncate(host.trim_end_matches('.').len());
if host.is_empty()
|| host == "localhost"
|| host.ends_with(".local")
@@ -167,8 +167,13 @@ pub fn is_public_http_url(raw: &str) -> bool {
}
std::net::IpAddr::V6(v6) => {
let s = v6.segments();
// fc00::/7 (unique-local) or fe80::/10 (link-local)
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
// (site-local, deprecated by RFC 3879 and so never a legitimate
// peer address, but still routable on networks that kept it).
if (s[0] & 0xfe00) == 0xfc00
|| (s[0] & 0xffc0) == 0xfe80
|| (s[0] & 0xffc0) == 0xfec0
{
return false;
}
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
@@ -183,6 +188,13 @@ pub fn is_public_http_url(raw: &str) -> bool {
}
}
}
} else if !host.contains('.') {
// A name with no dot at all. The `.local` / `.internal` rules above are
// suffix checks, so a single label slips both, and the resolver decides
// what it means by appending a search domain we cannot see from here.
// Only reachable when the host did not parse as an IP literal, which is
// what keeps bracketed IPv6 (also dotless) on the accepting path.
return false;
}
true
}
@@ -520,7 +532,7 @@ mod tests {
#[test]
fn accepts_public_https_and_http() {
assert!(is_public_http_url("https://node.gitlawb.com"));
assert!(is_public_http_url("https://node.twigpine.com"));
assert!(is_public_http_url("https://manila.gitlawb.com/"));
assert!(is_public_http_url("http://203.0.113.10:7545"));
}
@@ -544,7 +556,7 @@ mod tests {
"http://[::1]:7545",
"http://gitlawb-node.internal:7545",
"http://my-node.local/",
"ftp://node.gitlawb.com",
"ftp://node.twigpine.com",
"not-a-url",
"",
// Trailing-dot FQDN of an internal host.
@@ -577,6 +589,46 @@ mod tests {
}
}
#[test]
fn rejects_dotless_single_label_hosts() {
// `.local` and `.internal` are suffix checks, so a name with no dot at
// all never matches one. A single-label name is completed by whatever
// search domain the resolver is configured with, which is not a
// destination we can reason about from the URL.
for bad in [
"http://local/",
"http://internal/",
"http://intranet/",
"http://wpad/",
"http://metadata/",
"http://local./",
"http://internal.:7545",
] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
#[test]
fn rejects_deprecated_site_local_v6() {
// fec0::/10, deprecated by RFC 3879. The link-local mask (0xffc0 ==
// 0xfe80) does not cover it: 0xfec0 & 0xffc0 is 0xfec0.
for bad in ["http://[fec0::1]/", "http://[feff:ffff::1]:7545"] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
#[test]
fn rejects_repeated_trailing_root_dots() {
// Stripping a single root dot leaves `localhost.`, which matches
// neither the equality nor the suffix check. The subdomain form
// (`node.localhost..`) is not asserted here: `.localhost` is not yet a
// rejected suffix on this base, so that case belongs to whichever
// change adds it rather than to this one.
for bad in ["http://localhost../", "http://localhost...:7545"] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
#[test]
fn accepts_6to4_and_nat64_wrapping_public_v4() {
// Fold-and-recheck stays consistent with the mapped/compatible handling:
+19 -19
View File
@@ -50,6 +50,17 @@ pub async fn create_pr(
let target_branch = req
.target_branch
.unwrap_or_else(|| record.default_branch.clone());
// Validate both refs before they are stored, since both are later
// interpolated into git argv (git diff / worktree add / merge). Validate the
// RESOLVED target, not just a caller-supplied one: create_repo also gates
// default_branch, but validating here as well means a PR can never feed the
// git sink an unchecked ref even if a default was poisoned by an older row
// or a future path that skips create_repo's gate.
crate::api::validate_git_ref_with_git(&state.git_bin, &req.source_branch)
.map_err(crate::api::map_git_ref_validation_error)?;
crate::api::validate_git_ref_with_git(&state.git_bin, &target_branch)
.map_err(crate::api::map_git_ref_validation_error)?;
let number = state.db.next_pr_number(&record.id).await?;
let now = Utc::now().to_rfc3339();
@@ -209,9 +220,6 @@ pub async fn merge_pr(
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
}
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
// unthrottled; reuse the push handler's mapping so the two cannot drift.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
@@ -228,7 +236,10 @@ pub async fn merge_pr(
);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(merge_result.is_ok()).await;
// Short-circuit on a refused publish before the PR is marked merged and
// before the webhook fires. Both are irreversible announcements of a merge
// commit that only exists on this node's disk.
guard.release(merge_result.is_ok()).await.into_result()?;
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -428,12 +439,8 @@ pub async fn list_comments(
Ok(Json(serde_json::json!({ "comments": comments })))
}
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
/// 500 git error that says nothing about retrying.
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
/// pool must shed 503 + Retry-After.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
@@ -457,20 +464,15 @@ mod lock_pool_shed_tests {
}
}
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
/// capacity, not advisory-lock contention on this repo), and the must-not case
/// once that connection is back.
#[sqlx::test]
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool.clone()).await;
// One lock-pool connection with a short checkout timeout, so a single held
// guard exhausts it promptly rather than at the pool default.
state.repo_store = crate::git::repo_store::RepoStore::new(
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
None,
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
std::time::Duration::from_secs(300),
);
let repo = seed_repo(owner, "lp-merge");
@@ -524,9 +526,7 @@ mod lock_pool_shed_tests {
"merge_pr: a capacity shed must tell the client when to retry"
);
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
// fails later on the nonexistent on-disk repo, which is a git 500).
held.release(false).await;
let _ = held.release(false).await;
let admitted = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
//! DID resolution — look up a node by its gitlawb DID.
//! DID resolution — look up a node by its Twigpine DID.
//!
//! First checks the local peers table (HTTP-layer peer discovery),
//! then queries the Kademlia DHT via the libp2p swarm.
@@ -14,7 +14,7 @@ use crate::state::AppState;
/// GET /api/v1/resolve/{did}
///
/// Resolves a gitlawb DID to an HTTP URL and p2p multiaddr.
/// Resolves a Twigpine DID to an HTTP URL and p2p multiaddr.
/// Checks local peer cache first, then falls back to Kademlia DHT lookup.
pub async fn resolve_did(
State(state): State<AppState>,
+248 -36
View File
@@ -34,6 +34,38 @@ use gitlawb_core::http_sig::{
};
use gitlawb_core::identity::verify;
/// Per-route-group ceiling on how much request body [`require_signature`] will
/// buffer BEFORE it has authenticated anything.
///
/// This is middleware state rather than a constant because a single number cannot
/// serve both a 2 GB git push and a 2 MB profile update: the git write group
/// passes `GITLAWB_MAX_PACK_BYTES`, every other signed group passes
/// [`crate::server::SIGNED_BODY_LIMIT`]. Making it a required piece of state means
/// a new signed route group cannot be wired up without choosing a limit.
#[derive(Clone, Copy, Debug)]
pub struct SignatureBodyLimit(pub usize);
/// The hint for an unresolvable DID, chosen by method. The hint is only true
/// for a method we have no resolver for: a did:key that parsed and then failed
/// on its key material (a small-order key, wrong multicodec, wrong length)
/// would be sent looking for the wrong problem by the alpha-support wording.
fn unresolvable_did_hint(key_id: &Did) -> &'static str {
if key_id.is_did_key() {
"the DID is a did:key whose key material did not resolve"
} else {
"only did:key is supported in alpha"
}
}
/// Bound the echoed keyid in the unresolvable-DID message. `key_id` is
/// unauthenticated request input, so reflecting it back at whatever length the
/// caller chose would amplify attacker input into the response body. The bound
/// is slack (a did:key method-id is a fixed 48 characters) and only defends
/// the echo, not the resolution.
fn bounded_did_echo(key_id: &str) -> String {
key_id.chars().take(96).collect()
}
/// Axum middleware that enforces HTTP Signature authentication (RFC 9421).
///
/// Every write request must carry:
@@ -48,20 +80,52 @@ use gitlawb_core::identity::verify;
/// 4. Resolves the did:key to an Ed25519 VerifyingKey
/// 5. Rebuilds the signing string and verifies the Ed25519 signature
/// 6. Verifies Content-Digest matches the request body
pub async fn require_signature(request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream
pub async fn require_signature(
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
request: Request,
next: Next,
) -> Response {
verify_signature(limit, request, next).await
}
/// The signature check itself, shared by [`require_signature`] and the optional
/// variant. `limit` bounds the pre-auth buffer; see [`SignatureBodyLimit`].
async fn verify_signature(limit: usize, request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream.
//
// The buffer is bounded, because this runs BEFORE any identity check: an
// unauthenticated caller with no DID, no signature and no repo reaches this
// line. Nothing upstream supplies the bound. On the git write routes
// `RequestBodyLimitLayer` sits on the inner router, and a later `.layer()` is
// the OUTER service, so this middleware runs first and sees the raw body. On
// every other signed group axum's 2 MB `DefaultBodyLimit` is enforced by the
// `Bytes`/`Json` extractors through a request extension, which collecting the
// body here bypasses. `Limited` stops reading at the limit rather than reading
// everything and then measuring it, which is the property that matters.
let (parts, body) = request.into_parts();
let body_bytes =
match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => return (
let body_bytes = match http_body_util::Limited::new(body, limit).collect().await {
Ok(collected) => collected.to_bytes(),
// Over the limit: a distinct status from the 401 an unsigned request gets
// and the 400 below, so a client can tell "too big" from "not authenticated"
// and from "your body did not arrive".
Err(e) if e.is::<http_body_util::LengthLimitError>() => return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({
"error": "body_too_large",
"message": format!("request body exceeds the {limit}-byte limit for this route"),
})),
)
.into_response(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "unreadable_body", "message": "could not read request body" }),
),
)
.into_response(),
};
.into_response()
}
};
let sig_input = parts
.headers
@@ -120,7 +184,7 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
COVERED_COMPONENTS.join(", "),
missing.join(", ")
),
"hint": "See https://gitlawb.com/agents#authentication",
"hint": "See https://twigpine.com/agents#authentication",
})),
)
.into_response();
@@ -145,11 +209,14 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
StatusCode::BAD_REQUEST,
Json(json!({
"error": "unresolvable_did",
"message": format!("cannot resolve DID '{}': {e}", sig.key_id),
"hint": "only did:key is supported in alpha",
"message": format!(
"cannot resolve DID '{}': {e}",
bounded_did_echo(&sig.key_id.to_string())
),
"hint": unresolvable_did_hint(&sig.key_id),
})),
)
.into_response()
.into_response();
}
};
@@ -162,17 +229,34 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
.unwrap_or("/")
.to_string();
let content_digest = parts
// Required, never defaulted. The signing string is rebuilt from the request,
// so an absent header would make the covered `content-digest` empty on both
// sides: verification would pass and the digest-versus-body comparison below
// would have nothing to compare, leaving a signed request free to carry a
// body its signature never covered. `sign_request` always emits the header,
// so refusing here costs no conforming client anything.
let content_digest = match parts
.headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
{
Some(v) => v.to_string(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "missing_content_digest",
"message": "Content-Digest header is required on a signed request",
})),
)
.into_response()
}
};
let mut request_values: HashMap<String, String> = HashMap::new();
request_values.insert("@method".to_string(), method);
request_values.insert("@path".to_string(), path_and_query);
request_values.insert("content-digest".to_string(), content_digest);
request_values.insert("content-digest".to_string(), content_digest.clone());
// The @signature-params value is the part of Signature-Input after "sig1="
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input);
@@ -217,23 +301,18 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
.into_response();
}
// Verify Content-Digest matches the actual request body
if let Some(claimed) = parts
.headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
{
let actual = compute_content_digest(&body_bytes);
if claimed != actual {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "content_digest_mismatch",
"message": "Content-Digest does not match request body",
})),
)
.into_response();
}
// Verify Content-Digest matches the actual request body. Unconditional: the
// header's presence was established above, so there is no branch here that
// skips the comparison.
if content_digest != compute_content_digest(&body_bytes) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "content_digest_mismatch",
"message": "Content-Digest does not match request body",
})),
)
.into_response();
}
tracing::info!(did = %sig.key_id, "✓ authenticated request");
@@ -248,11 +327,17 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
/// continue when no signature attempt was made.
pub async fn optional_signature(request: Request, next: Next) -> Response {
pub async fn optional_signature(
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
request: Request,
next: Next,
) -> Response {
let has_signature_headers = request.headers().contains_key("signature-input")
|| request.headers().contains_key("signature");
if has_signature_headers {
return require_signature(request, next).await;
// Same pre-auth buffer, same bound: presenting signature headers is not
// authentication, so this path is as reachable as the required one.
return verify_signature(limit, request, next).await;
}
next.run(request).await
}
@@ -380,7 +465,7 @@ fn human_detected(message: &str) -> impl IntoResponse {
"error": "not_an_agent",
"message": message,
"hint": "gl identity new && gl register",
"docs": "https://gitlawb.com/agents",
"docs": "https://twigpine.com/agents",
})),
)
}
@@ -423,6 +508,58 @@ mod tests {
assert!(validate_ucan_chain(&token, &node_did, &agent_did).is_ok());
}
/// The two-branch hint selection is the point of the unresolvable-DID
/// change: a did:key that failed on its key material must be told the key
/// material did not resolve, and any other method must be told only did:key
/// is supported. A regression that drops the is_did_key() check would send
/// a did:key caller after the wrong problem, so both branches are pinned.
#[test]
fn unresolvable_did_hint_distinguishes_key_material_from_method() {
// A real did:key whose key material fails to resolve (small-order):
// key-material hint.
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let weak_vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).unwrap();
let weak_did = Did::from_verifying_key(&weak_vk);
assert!(
weak_did.to_verifying_key().is_err(),
"fixture precondition: small-order did:key must not resolve"
);
assert_eq!(
unresolvable_did_hint(&weak_did),
"the DID is a did:key whose key material did not resolve"
);
// A method with no resolver: alpha-support hint.
let web = Did::web("example.com");
assert!(
web.to_verifying_key().is_err(),
"fixture precondition: did:web must not resolve locally"
);
assert_eq!(
unresolvable_did_hint(&web),
"only did:key is supported in alpha"
);
}
/// The unresolvable-DID message echoes the keyid, which is unauthenticated
/// request input. The echoed value must be bounded so a hostile caller
/// cannot amplify arbitrary-length input into the response body.
#[test]
fn unresolvable_did_message_bounds_the_echoed_keyid() {
let long_keyid = format!("did:key:z{}", "A".repeat(2000));
let shown = bounded_did_echo(&long_keyid);
assert!(
shown.len() <= 96,
"echoed keyid must be bounded, got {} chars",
shown.len()
);
assert!(
!shown.contains(&"A".repeat(1000)),
"the full keyid must not survive into the message"
);
}
#[test]
fn validate_ucan_chain_wrong_issuer() {
let node = Keypair::generate();
@@ -517,6 +654,7 @@ mod tests {
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
@@ -636,4 +774,78 @@ mod tests {
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body_json["error"], "invalid_ucan");
}
/// A request that signs `content-digest` as the EMPTY string and then sends
/// no Content-Digest header at all must be refused before any handler runs.
///
/// This is the whole point of requiring the header rather than defaulting it:
/// the middleware rebuilds the signing string from the request, so an absent
/// header makes the covered digest empty on both sides. The Ed25519 check
/// passes, the body-versus-digest comparison is skipped for want of a header,
/// and a signed request carries a body the signature never covered.
#[tokio::test]
async fn signed_request_without_content_digest_is_rejected() {
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::atomic::{AtomicBool, Ordering};
static REACHED: AtomicBool = AtomicBool::new(false);
let kp = Keypair::generate();
let did = kp.did();
let created = chrono::Utc::now().timestamp();
let signature_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
let sig_params_value = &signature_input["sig1=".len()..];
let mut values: HashMap<String, String> = HashMap::new();
values.insert("@method".to_string(), "POST".to_string());
values.insert("@path".to_string(), "/x".to_string());
// Exactly what the middleware derives when the header is absent.
values.insert("content-digest".to_string(), String::new());
let signing_string =
build_signing_string(COVERED_COMPONENTS, sig_params_value, &values).unwrap();
let signature = format!(
"sig1=:{}:",
STANDARD.encode(kp.sign(signing_string.as_bytes()).to_bytes())
);
let app = Router::new()
.route(
"/x",
axum::routing::post(|| async {
REACHED.store(true, Ordering::SeqCst);
StatusCode::OK
}),
)
.layer(middleware::from_fn_with_state(
SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
require_signature,
));
let req = Request::builder()
.method("POST")
.uri("/x")
.header("signature-input", signature_input)
.header("signature", signature)
.body(Body::from("a body no signature covers"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a signed request with no Content-Digest header must be refused"
);
let bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body["error"], "missing_content_digest",
"the refusal must name the absent Content-Digest header"
);
assert!(
!REACHED.load(Ordering::SeqCst),
"the handler must never see a request whose body no signature covers"
);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Embedded seed list of public Gitlawb network nodes.
//! Embedded seed list of public Twigpine network nodes.
//!
//! This module parses `bootstrap-peers.json` (embedded at compile time) and
//! merges its contents into the runtime config so a fresh `docker compose up`
+772 -47
View File
@@ -1,5 +1,5 @@
use clap::Parser;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Upper bound on `git_service_timeout_secs`, `ipfs_request_budget_secs`, and
/// `ipfs_resolve_budget_secs`, in seconds (100 years).
@@ -29,7 +29,7 @@ use std::path::PathBuf;
pub const GIT_SERVICE_TIMEOUT_SECS_MAX: u64 = 100 * 365 * 24 * 60 * 60;
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
#[command(name = "gitlawb-node", about = "Twigpine node daemon", version)]
pub struct Config {
/// Directory where bare git repositories are stored
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
@@ -67,7 +67,9 @@ pub struct Config {
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
pub bootstrap_peers: Vec<String>,
/// Require RFC 9421 signatures on peer announce/sync write routes.
/// Require a peer write to prove its DID on any transport: RFC 9421
/// signatures on the peer announce/sync write routes, and a payload
/// signature on gossip ref-update events.
/// Keep false during rolling upgrades so existing live nodes can still gossip.
#[arg(
long,
@@ -120,6 +122,18 @@ pub struct Config {
#[arg(long, env = "GITLAWB_P2P_PORT", default_value_t = 7546)]
pub p2p_port: u16,
/// Path to the persistent libp2p identity key. A value that cannot name a
/// securable key file (a bare filename, `..` traversal, a trailing
/// separator, the filesystem root, or a `~/` path that escapes home) is
/// refused before the node binds. A problem with the storage itself leaves
/// p2p off while HTTP keeps serving, logged as p2p_identity_key_load_failed.
/// With a relative path the node verifies the working directory (ownership
/// and write permissions) before using it. A 0755 key directory is tightened
/// on start; rotate the key only if the directory was group/world-writable
/// or the key was group/world-readable.
#[arg(long, env = "GITLAWB_P2P_KEY", default_value = "~/.gitlawb/p2p.key")]
pub p2p_key_path: String,
/// libp2p bootstrap multiaddrs (comma-separated)
/// Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
#[arg(long, env = "GITLAWB_P2P_BOOTSTRAP", value_delimiter = ',')]
@@ -252,11 +266,9 @@ pub struct Config {
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
/// (connections open lazily). Size against the database server's
/// max_connections, remembering admin tooling opens its own pool. Each
/// concurrent write pins one pooled connection for its whole duration (the
/// advisory lock in `repo_store::acquire_write` is connection-affine), so this
/// must exceed `max_concurrent_git_pushes` by `DB_POOL_APP_HEADROOM` or slow
/// pushes starve every other DB path — enforced by `Config::validate`.
/// max_connections, remembering admin tooling opens its own pool. Writes pin
/// the dedicated lock pool, not this one; this pool still needs enough headroom
/// for ordinary request handlers and metadata paths (`DB_POOL_APP_HEADROOM`).
#[arg(
long,
env = "GITLAWB_DB_MAX_CONNECTIONS",
@@ -265,6 +277,46 @@ pub struct Config {
)]
pub db_max_connections: u32,
/// Maximum connections in the dedicated advisory-lock pool, which is separate
/// from the main pool above.
///
/// Size this against the expected peak number of concurrent distinct-repo
/// writers, NOT small. Every in-flight repo write pins one connection here for
/// its whole duration (the write, its metadata tail, and the bounded archive
/// upload), so this value is a hard ceiling on simultaneous writes node-wide.
/// Keeping it separate from GITLAWB_DB_MAX_CONNECTIONS is what stops a push
/// burst from starving ordinary request handlers; the cost is that
/// (main pool + lock pool) must fit inside the database server's
/// max_connections, times the number of nodes, plus admin tooling.
#[arg(
long,
env = "GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS",
default_value_t = 40,
value_parser = clap::value_parser!(u32).range(1..)
)]
pub db_lock_pool_max_connections: u32,
/// Upper bound, in seconds, on any single object-storage transfer that runs
/// while the per-repo advisory lock is HELD.
///
/// Two bounded spans exist, and the bound applies per span. The acquire-side
/// refresh in `acquire_write` covers the existence HEAD and the download
/// together under ONE budget (it runs after the lock is taken and before the
/// guard is constructed), and the archive upload in `release` gets its own.
/// Worst-case slot occupancy is therefore about twice this value plus the git
/// work between them, not one times this value. Both used to be free, because the lock's
/// connection was returned to the pool immediately; now that a write guard
/// pins a lock-pool connection for its whole lifetime, an unbounded transfer
/// holds that slot, and enough stalled transfers deny every write on the node.
/// This is the bound that keeps a stall from becoming an outage.
#[arg(
long,
env = "GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS",
default_value_t = 300,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub lock_held_transfer_timeout_secs: u64,
/// Maximum time a request waits for a pool connection before failing with
/// 503, in seconds. Bounds queueing when the database is slow or down.
#[arg(
@@ -359,15 +411,14 @@ pub struct Config {
/// error rather than a boot-time panic).
///
/// CONNECTION BUDGET. A push holds a Postgres connection from the node's separate
/// advisory-lock pool for the whole receive-pack, and that pool is sized from this
/// knob (this value + 8, clamped to 64 in `main.rs`). The node's total ceiling is
/// therefore `db_max_connections` (default 48) + the lock pool (default 40), i.e.
/// 88 by default, and at most `db_max_connections` + 64. Size BOTH against the
/// database server's `max_connections`: `db_max_connections`' own doc predates the
/// lock pool and no longer covers most of the node's connections. The +8 headroom
/// is shared with the three non-push `acquire_write` callers (`api/issues.rs` x2,
/// `api/pulls.rs`). Raising this knob past the clamp does NOT buy more lock-pool
/// connections; pushes beyond it wait briefly and then shed a 503 + Retry-After.
/// advisory-lock pool (`GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS`, default 40) for
/// the whole receive-pack. That pool must be at least this value plus
/// `DB_LOCK_POOL_NON_PUSH_HEADROOM` so admitted pushes and non-push mutations
/// can each pin a lock-pool connection for their whole duration. Size BOTH
/// pools against the database server's `max_connections`: the main pool
/// (`GITLAWB_DB_MAX_CONNECTIONS`, default 48) serves ordinary handlers, and the
/// lock pool serves writes. Raising this knob does not raise the lock pool;
/// set `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` explicitly.
#[arg(
long,
env = "GITLAWB_MAX_CONCURRENT_GIT_PUSHES",
@@ -712,40 +763,160 @@ impl Config {
/// Resolve ~ in key_path
pub fn resolved_key_path(&self) -> PathBuf {
if self.key_path.starts_with("~/") {
if let Some(home) = dirs_next::home_dir() {
return home.join(&self.key_path[2..]);
let suffix = &self.key_path[2..];
if !crate::p2p::tilde_suffix_escapes_home(suffix) {
if let Some(home) = dirs_next::home_dir() {
return home.join(suffix);
}
}
}
PathBuf::from(&self.key_path)
}
/// Resolve ~ in p2p_key_path
pub fn resolved_p2p_key_path(&self) -> PathBuf {
if self.p2p_key_path.starts_with("~/") {
let suffix = &self.p2p_key_path[2..];
if !crate::p2p::tilde_suffix_escapes_home(suffix) {
if let Some(home) = dirs_next::home_dir() {
return home.join(suffix);
}
}
}
PathBuf::from(&self.p2p_key_path)
}
/// DB connections reserved for everything other than held write-locks: auth
/// lookups, visibility-rule reads, the post-receive tail's own DB writes, and
/// admin tooling. A write pins one pooled connection for its whole duration, so
/// the pool must clear the concurrent-write cap by at least this margin.
pub const DB_POOL_APP_HEADROOM: u32 = 8;
/// Lock-pool slots reserved for non-push `acquire_write` callers (issue create,
/// close, merge) so a saturated push budget cannot turn ordinary mutations into
/// lock-pool exhaustion on unrelated repositories.
pub const DB_LOCK_POOL_NON_PUSH_HEADROOM: u32 = 8;
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
/// catches combinations that ship a denial-of-service under otherwise-valid
/// values. Call once at startup and fail fast on `Err`.
pub fn validate(&self) -> Result<(), String> {
// A write pins one pooled connection for its whole duration (the
// connection-affine advisory lock in repo_store::acquire_write), and
// concurrent writes are capped at max_concurrent_git_pushes. If the pool
// does not exceed that cap by DB_POOL_APP_HEADROOM, a burst of slow pushes
// drains every connection and every other DB path 503s. (#174 F1)
let floor = (self.max_concurrent_git_pushes as u64) + (Self::DB_POOL_APP_HEADROOM as u64);
if (self.db_max_connections as u64) < floor {
// Concurrent git writes pin the dedicated lock pool for their whole
// duration (connection-affine advisory lock in acquire_write). The main
// pool no longer carries that occupancy.
let lock_floor = self
.max_concurrent_git_pushes
.saturating_add(Self::DB_LOCK_POOL_NON_PUSH_HEADROOM as usize);
if (self.db_lock_pool_max_connections as usize) < lock_floor {
return Err(format!(
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least max_concurrent_git_pushes ({}) \
+ {} headroom = {}: each concurrent write pins one pooled connection for its whole \
duration, so a smaller pool lets a burst of slow pushes starve every other DB path.",
self.db_max_connections,
"GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS ({}) must be at least \
max_concurrent_git_pushes ({}) plus {} for non-push mutations \
that share the lock pool",
self.db_lock_pool_max_connections,
self.max_concurrent_git_pushes,
Self::DB_POOL_APP_HEADROOM,
floor
Self::DB_LOCK_POOL_NON_PUSH_HEADROOM
));
}
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
if (self.db_max_connections as u64) < main_floor {
return Err(format!(
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least {} for non-git \
database paths",
self.db_max_connections, main_floor
));
}
// Identity is always loaded, so a directory-valued or escaping spelling
// is boot-fatal here. Bare `identity.pem` stays legal; the p2p "must
// include a dedicated directory" rule is not imported.
if self.key_path.starts_with("~/") {
let suffix = &self.key_path[2..];
if crate::p2p::tilde_suffix_escapes_home(suffix) {
return Err(format!(
"GITLAWB_KEY ({}) must stay inside the home directory after `~/` \
expansion; rooted suffixes such as `~//etc/identity.pem`, drive-prefixed \
suffixes, and `..` traversal are refused",
self.key_path
));
}
}
let identity_key_path = self.resolved_key_path();
if self.key_path.starts_with("~/") && identity_key_path == Path::new(&self.key_path) {
return Err(format!(
"GITLAWB_KEY ({}) starts with `~/` but no home directory could be resolved, \
so it would name a literal `~` directory relative to the working directory. \
Set an absolute path such as /data/keys/identity.pem.",
self.key_path
));
}
if crate::p2p::path_denotes_a_directory(&identity_key_path, Some(&self.key_path))
|| identity_key_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(format!(
"GITLAWB_KEY ({}) must name a key file, not a directory; a trailing separator, \
a final `.` or `..` component, and `..` traversal are refused so the path \
cannot retarget a parent",
self.key_path
));
}
// P2P is optional: an HTTP-only node with GITLAWB_P2P_PORT=0 never loads
// or creates this key, so refusing startup on an unused path would be a
// silent outage with no security benefit.
if self.p2p_port > 0 {
// A p2p key path naming no directory puts the node's private key in
// whatever directory the process was started from. The node cannot
// protect that: `ensure_key_dir` would have to chmod a directory the
// operator never nominated as a key directory, and a directory it
// cannot secure is one where any local user with write access can
// replace the key and choose the node's libp2p identity. Refuse it here,
// where the denial actually stops the process, rather than in the p2p
// start path, where main.rs logs the error and keeps serving with a
// green /health.
//
// Decided lexically on the resolved path: `canonicalize` would fail on a
// parent that does not exist yet (the shipped `~/.gitlawb` default, and
// every container's first boot), and comparing against the process
// working directory would reject `/data/p2p.key` under the image's
// WORKDIR, an absolute directory the operator did name.
// `resolved_p2p_key_path` expands a leading `~/` only when a home
// directory is resolvable, and otherwise hands back the literal string.
// That would leave the shipped default naming a directory called `~`
// relative to wherever the process started, which is a real directory
// the node would create and chmod, and whose location moves with the
// working directory. It passes the check below because `~` is an
// ordinary path component, so it has to be caught separately.
if self.p2p_key_path.starts_with("~/") {
let suffix = &self.p2p_key_path[2..];
if crate::p2p::tilde_suffix_escapes_home(suffix) {
return Err(format!(
"GITLAWB_P2P_KEY ({}) must stay inside the home directory after `~/` \
expansion; rooted suffixes such as `~//etc/p2p.key`, drive-prefixed \
suffixes, and `..` traversal are refused",
self.p2p_key_path
));
}
}
let p2p_key_path = self.resolved_p2p_key_path();
if self.p2p_key_path.starts_with("~/") && p2p_key_path == Path::new(&self.p2p_key_path)
{
return Err(format!(
"GITLAWB_P2P_KEY ({}) starts with `~/` but no home directory could be resolved, \
so it would name a literal `~` directory relative to the working directory. \
Set an absolute path such as /data/keys/p2p.key.",
self.p2p_key_path
));
}
// Lexical only: this runs before the listener binds and its
// failure stops the node, so it must not decide anything about
// live filesystem objects. Storage faults belong to the load path,
// which degrades instead of exiting.
let _ = crate::p2p::validate_p2p_key_config(&p2p_key_path, Some(&self.p2p_key_path))
.map_err(|e| e.to_string())?;
}
Ok(())
}
}
@@ -754,6 +925,25 @@ impl Config {
mod tests {
use super::*;
#[test]
fn lock_pool_size_defaults_to_40_and_rejects_zero() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).db_lock_pool_max_connections,
40
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "8"])
.db_lock_pool_max_connections,
8
);
// A zero-sized lock pool would deny every write, so clap must reject it
// rather than let a node boot into a state where no repo can be written.
assert!(
Config::try_parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "0"])
.is_err()
);
}
#[test]
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
assert_eq!(
@@ -1363,41 +1553,61 @@ mod tests {
);
}
/// #174 F1: a connection-affine write lock pins a pooled connection per
/// concurrent write, so the pool must clear `max_concurrent_git_pushes` by
/// `DB_POOL_APP_HEADROOM` or a push burst starves every other DB path.
/// `validate()` must reject an under-sized pool at boot.
/// #174 F1: concurrent git writes pin the dedicated lock pool, not the main
/// pool. `validate()` must reject an under-sized lock pool at boot.
#[test]
fn db_pool_must_clear_the_git_push_cap() {
// Shipped defaults validate (48 >= 32 + 8).
// Shipped defaults validate (lock pool 40 >= pushes 32 + non-push headroom 8).
Config::parse_from(["gitlawb-node"])
.validate()
.expect("default config must validate");
// An under-sized pool relative to the push cap is rejected (20 < 32 + 8).
let under = Config::parse_from([
// An under-sized lock pool relative to the push cap is rejected.
let under_lock = Config::parse_from([
"gitlawb-node",
"--db-max-connections",
"20",
"--db-lock-pool-max-connections",
"16",
"--max-concurrent-git-pushes",
"32",
]);
assert!(
under.validate().is_err(),
"db_max_connections 20 below max_concurrent_git_pushes 32 + headroom must be rejected"
under_lock.validate().is_err(),
"db_lock_pool_max_connections below max_concurrent_git_pushes + headroom must be rejected"
);
// Exactly at the floor validates (40 == 32 + 8).
let at_floor = Config::parse_from([
// Exactly at the push cap without non-push headroom is rejected.
let push_only = Config::parse_from([
"gitlawb-node",
"--db-lock-pool-max-connections",
"32",
"--max-concurrent-git-pushes",
"32",
]);
assert!(
push_only.validate().is_err(),
"db_lock_pool_max_connections equal to max_concurrent_git_pushes must be rejected"
);
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
let split = Config::parse_from([
"gitlawb-node",
"--db-max-connections",
"16",
"--db-lock-pool-max-connections",
"40",
"--max-concurrent-git-pushes",
"32",
]);
assert!(
at_floor.validate().is_ok(),
"db_max_connections at the floor (pushes + headroom) must validate"
split.validate().is_ok(),
"a small main pool with a large lock pool must validate"
);
// Main pool still needs the app headroom floor.
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
assert!(
under_main.validate().is_err(),
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
);
}
@@ -1447,4 +1657,519 @@ mod tests {
Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push
);
}
fn config_with_p2p_key(path: &str) -> Config {
Config::parse_from(["gitlawb-node", "--p2p-key-path", path])
}
fn config_with_key(path: &str) -> Config {
Config::parse_from(["gitlawb-node", "--key-path", path])
}
fn config_with_p2p_port_and_key(port: u16, path: &str) -> Config {
Config::parse_from([
"gitlawb-node",
"--p2p-port",
&port.to_string(),
"--p2p-key-path",
path,
])
}
/// The configuration half of the key-storage contract matrix: enabled
/// versus disabled p2p, crossed with the path spellings the contract
/// rules on. The filesystem-object half (which objects at and around the
/// key path boot or are refused) lives in `p2p::tests::
/// p2p_key_storage_contract_matrix`; nothing there runs unless this gate
/// lets a config through.
///
/// Rule 1 is proven through its own rejections: every disabled row uses a
/// path that an enabled row shows to be invalid, so `Ok` on the disabled
/// row is direct evidence the validator never resolved or inspected the
/// unused key storage.
#[test]
fn p2p_key_storage_contract_config_matrix() {
enum Expect {
Accepted,
Rejected(&'static str),
}
struct Row {
port: u16,
path: &'static str,
expect: Expect,
/// Skip when no home directory resolves: the row's outcome is
/// about `~/` expansion, which then legitimately fails earlier
/// with the no-home error instead.
needs_home: bool,
}
let rows = [
// Rule 1: disabled p2p leaves its unused key storage alone.
Row {
port: 0,
path: "p2p.key",
expect: Expect::Accepted,
needs_home: false,
},
Row {
port: 0,
path: "~/",
expect: Expect::Accepted,
needs_home: false,
},
Row {
port: 0,
path: "~//etc/p2p.key",
expect: Expect::Accepted,
needs_home: false,
},
Row {
port: 0,
path: "~/../etc/p2p.key",
expect: Expect::Accepted,
needs_home: false,
},
Row {
port: 0,
path: "",
expect: Expect::Accepted,
needs_home: false,
},
// Enabled: ordinary absolute and home-relative paths pass, a
// doubled separator in an absolute path is harmless (it does not
// re-root anything), and the shipped default resolves beneath
// home (asserted separately below).
Row {
port: 7546,
path: "/data/keys/p2p.key",
expect: Expect::Accepted,
needs_home: false,
},
Row {
port: 7546,
path: "/data//keys/p2p.key",
expect: Expect::Accepted,
needs_home: false,
},
Row {
port: 7546,
path: "~/.gitlawb/p2p.key",
expect: Expect::Accepted,
needs_home: true,
},
// Rule 2: a `~/` suffix that would re-root or walk out of home is
// refused at parse time, before any join. These fire whether or
// not a home directory resolves, because the suffix is judged
// before expansion.
Row {
port: 7546,
path: "~//etc/p2p.key",
expect: Expect::Rejected("inside the home directory"),
needs_home: false,
},
Row {
port: 7546,
path: "~/../etc/p2p.key",
expect: Expect::Rejected("inside the home directory"),
needs_home: false,
},
// Rule 3's lexical pre-checks, still gated on the port.
Row {
port: 7546,
path: "p2p.key",
expect: Expect::Rejected("directory"),
needs_home: false,
},
Row {
port: 7546,
path: "~/",
expect: Expect::Rejected("must name a key file"),
needs_home: true,
},
Row {
port: 7546,
path: "/data/keys/.",
expect: Expect::Rejected("must name a key file"),
needs_home: false,
},
Row {
port: 7546,
path: "/data/keys/./.",
expect: Expect::Rejected("must name a key file"),
needs_home: false,
},
Row {
port: 7546,
path: "~/.gitlawb/.",
expect: Expect::Rejected("must name a key file"),
needs_home: true,
},
];
let have_home = dirs_next::home_dir().is_some();
for row in rows {
if row.needs_home && !have_home {
continue;
}
let result = config_with_p2p_port_and_key(row.port, row.path).validate();
match row.expect {
Expect::Accepted => assert!(
result.is_ok(),
"[port={} path={:?}] must be accepted, got: {result:?}",
row.port,
row.path
),
Expect::Rejected(needle) => {
let err = result.expect_err(&format!(
"[port={} path={:?}] must be rejected",
row.port, row.path
));
assert!(
err.contains(needle),
"[port={} path={:?}] rejection must mention {needle:?}, got: {err}",
row.port,
row.path
);
}
}
}
}
/// Rule 1's absence-of-IO half against a real on-disk trap: a disabled
/// node pointed at a key path whose parent is a symlink — a shape the
/// enabled path refuses and must never chmod — validates clean and leaves
/// the trap directory exactly as it was.
#[cfg(unix)]
#[test]
fn p2p_disabled_key_storage_is_never_inspected_or_mutated() {
use std::os::unix::fs::PermissionsExt;
let base = tempfile::tempdir().unwrap();
let target = base.path().join("real");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
let link = base.path().join("keys");
std::os::unix::fs::symlink(&target, &link).unwrap();
let key_path = link.join("p2p.key");
let config = Config::parse_from([
"gitlawb-node",
"--p2p-port",
"0",
"--p2p-key-path",
key_path.to_str().unwrap(),
]);
assert!(
config.validate().is_ok(),
"a disabled node must not validate the unused key path"
);
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "the symlink target's mode must stay unchanged");
assert_eq!(
std::fs::read_dir(&target).unwrap().count(),
0,
"nothing may be created behind the symlink"
);
}
/// The other half of the scoping pair: p2p ENABLED, hostile resource on
/// disk, and `validate` still passes.
///
/// This is the finding-2 boundary. A symlinked parent, a regular file
/// where the key directory should be, and an unreadable parent are live
/// storage facts, not properties of the configured value, so they belong
/// to the load path, which degrades. Deciding them here made the node exit
/// before binding for exactly the cases README and `.env.example` promise
/// leave HTTP up. Each row also proves validate touched nothing: an
/// inspection is still an observation, and the port-zero test above is the
/// disabled half of the same pair.
#[cfg(unix)]
#[test]
fn enabled_p2p_does_not_treat_live_storage_faults_as_configuration_errors() {
use std::os::unix::fs::PermissionsExt;
fn snapshot(root: &std::path::Path) -> Vec<String> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(root) {
for e in entries.flatten() {
let md = std::fs::symlink_metadata(e.path()).unwrap();
out.push(format!(
"{} dir={} link={} mode={:04o}",
e.path().display(),
md.is_dir(),
md.is_symlink(),
md.permissions().mode() & 0o7777
));
}
}
out.sort();
out
}
/// Builds a trap under `base` and returns the key path inside it.
type BuildTrap = Box<dyn Fn(&std::path::Path) -> std::path::PathBuf>;
// (label, build the trap, the key path inside it)
let cases: Vec<(&str, BuildTrap)> = vec![
(
"symlinked parent",
Box::new(|base: &std::path::Path| {
let target = base.join("real");
std::fs::create_dir(&target).unwrap();
let link = base.join("keys");
std::os::unix::fs::symlink(&target, &link).unwrap();
link.join("p2p.key")
}),
),
(
"regular file as the key directory",
Box::new(|base: &std::path::Path| {
let file = base.join("keys");
std::fs::write(&file, b"not a directory").unwrap();
file.join("p2p.key")
}),
),
(
"unreadable parent",
Box::new(|base: &std::path::Path| {
let locked = base.join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.unwrap();
locked.join("keys").join("p2p.key")
}),
),
];
for (label, build) in cases {
let base = tempfile::tempdir().unwrap();
let key_path = build(base.path());
let before = snapshot(base.path());
let config = Config::parse_from([
"gitlawb-node",
"--p2p-port",
"7546",
"--p2p-key-path",
key_path.to_str().unwrap(),
]);
let verdict = config.validate();
assert!(
verdict.is_ok(),
"{label}: a live storage fault is not a configuration error, got: {verdict:?}"
);
assert_eq!(
snapshot(base.path()),
before,
"{label}: validation must not mutate the key tree"
);
// Leave the tempdir removable.
let locked = base.path().join("locked");
if locked.exists() {
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700));
}
}
}
/// Rule 2's positive direction: the shipped `~/` default resolves to a
/// path beneath the selected home, and suffixes that would escape are
/// left unexpanded (and then rejected by `validate`, as the matrix above
/// asserts) rather than joined and repaired after.
#[test]
fn p2p_tilde_expansion_stays_beneath_home() {
let Some(home) = dirs_next::home_dir() else {
return;
};
let resolved = config_with_p2p_key("~/.gitlawb/p2p.key").resolved_p2p_key_path();
assert!(
resolved.starts_with(&home),
"the resolved default must stay beneath home"
);
assert!(
resolved.ends_with(".gitlawb/p2p.key"),
"the resolved default must keep the configured suffix"
);
for path in ["~//etc/p2p.key", "~/../etc/p2p.key"] {
assert_eq!(
config_with_p2p_key(path).resolved_p2p_key_path(),
Path::new(path),
"an escaping suffix must never be joined onto home"
);
}
}
/// A p2p key path that names no directory component would put the node's
/// private key in whatever directory the process happens to be started from,
/// which `ensure_key_dir` cannot protect without tightening a directory the
/// operator never nominated. Reject it at boot instead.
#[test]
fn p2p_key_path_without_a_directory_component_is_rejected() {
for path in [
// No directory component at all.
"p2p.key",
"./p2p.key",
"././p2p.key",
"p2p.key/",
"",
// Looks like it names a directory and does not: each of these
// resolves back to the working directory or above it, so accepting
// them would defeat the check and chmod an unnominated directory.
"a/../p2p.key",
"./keys/../p2p.key",
"../p2p.key",
// Absolute too: the lexical parent is what gets chmodded, so these
// would tighten /data and / rather than the named directory.
"/data/keys/../p2p.key",
"/data/../p2p.key",
] {
let err = config_with_p2p_key(path)
.validate()
.expect_err(&format!("{path:?} names no directory and must be rejected"));
assert!(
err.contains("directory"),
"{path:?} must be rejected for naming no directory, got: {err}"
);
}
}
/// The mirror of the above, and the case that stops the predicate widening
/// into "reject every relative path". The shipped default is included on
/// purpose: a predicate that rejects it is a boot failure for every node.
#[test]
fn p2p_key_path_naming_a_directory_is_accepted() {
for path in [
"keys/p2p.key",
"./keys/p2p.key",
"/data/keys/p2p.key",
"/data/p2p.key",
"~/.gitlawb/p2p.key",
] {
assert!(
config_with_p2p_key(path).validate().is_ok(),
"{path:?} names a directory and must be accepted"
);
}
Config::parse_from(["gitlawb-node"])
.validate()
.expect("the shipped default p2p key path must validate");
}
/// `~/` expands to the home directory itself, which is a directory rather
/// than a key file, so it must be rejected before its parent is chmodded.
#[test]
fn p2p_key_path_is_checked_after_tilde_expansion() {
if dirs_next::home_dir().is_none() {
panic!("this test needs a home directory to distinguish raw from resolved");
}
let err = config_with_p2p_key("~/")
.validate()
.expect_err("`~/` must name a key file, not a directory");
assert!(
err.contains("must name a key file"),
"`~/` must be rejected before chmodding its parent, got: {err}"
);
}
#[test]
fn p2p_key_path_trailing_directory_separator_is_rejected() {
let err = config_with_p2p_key("/data/keys/")
.validate()
.expect_err("a trailing directory separator must be rejected");
assert!(
err.contains("must name a key file"),
"trailing `/` must be rejected before chmod, got: {err}"
);
}
/// A terminal `.` is a directory-valued spelling. Rust's Path drops it, so
/// `/data/keys/.` would otherwise be stored as the file `keys` under `/data`.
#[test]
fn p2p_key_path_terminal_dot_is_rejected() {
for path in ["/data/keys/.", "/data/keys/./.", "/data/keys/.//."] {
let err = config_with_p2p_key(path)
.validate()
.expect_err("a terminal `.` must be rejected as a directory");
assert!(
err.contains("must name a key file"),
"{path:?} must be refused before Path can retarget it, got: {err}"
);
}
}
/// Identity allows a bare filename; it does not allow a directory spelling
/// that Path would retarget onto a parent.
#[test]
fn identity_key_path_terminal_dot_is_rejected() {
for path in ["/data/keys/.", "/data/keys/./.", "/data/keys/.//."] {
let err = config_with_key(path)
.validate()
.expect_err("a terminal `.` must be rejected as a directory");
assert!(
err.contains("GITLAWB_KEY") && err.contains("must name a key file"),
"{path:?} must be refused before Path can retarget it, got: {err}"
);
}
}
#[test]
fn identity_key_path_trailing_directory_separator_is_rejected() {
let err = config_with_key("/data/keys/")
.validate()
.expect_err("a trailing directory separator must be rejected");
assert!(
err.contains("must name a key file"),
"trailing `/` must be rejected, got: {err}"
);
}
#[test]
fn identity_key_path_tilde_directory_is_rejected() {
let err = config_with_key("~/")
.validate()
.expect_err("`~/` must name a key file, not a directory");
assert!(
err.contains("must name a key file"),
"`~/` must be rejected, got: {err}"
);
}
#[test]
fn identity_key_path_tilde_escape_is_rejected() {
let err = config_with_key("~//etc/identity.pem")
.validate()
.expect_err("a `~/` spelling that escapes home must be rejected");
assert!(
err.contains("must stay inside the home directory"),
"tilde escape must be refused, got: {err}"
);
}
#[test]
fn identity_key_path_parent_dir_component_is_rejected() {
let err = config_with_key("/data/keys/../identity.pem")
.validate()
.expect_err("`..` in an identity path must be rejected");
assert!(
err.contains("..") || err.contains("must name a key file"),
"`..` must be refused so the parent cannot retarget, got: {err}"
);
}
#[test]
fn identity_key_path_bare_filename_is_accepted() {
for path in ["identity.pem", "./identity.pem", "/identity.pem"] {
config_with_key(path)
.validate()
.unwrap_or_else(|e| panic!("{path:?} is a legal identity path, got: {e}"));
}
Config::parse_from(["gitlawb-node"])
.validate()
.expect("the shipped default identity path must validate");
}
}
+93 -14
View File
@@ -265,11 +265,6 @@ pub struct Db {
}
impl Db {
/// Access the underlying Postgres connection pool.
pub fn pool(&self) -> &PgPool {
&self.pool
}
#[cfg(test)]
pub fn for_testing(pool: PgPool) -> Self {
Self { pool }
@@ -310,6 +305,46 @@ impl Db {
Ok(db)
}
/// Build the dedicated pool that advisory-lock connections come from.
///
/// Deliberately **lazy**: connections open on first use rather than at boot.
/// The main pool has to connect eagerly because it runs migrations, which is
/// why it needs `connect_db_with_retry`'s backoff and degraded-server
/// handoff. This pool has no startup work at all, so an eager connect would
/// only add a new way for the process to fail to boot, and would need a
/// second copy of that retry machinery to be safe. Being lazy removes the
/// failure mode instead of handling it: if Postgres is unreachable when the
/// first write arrives, that write fails on the pool's own acquire timeout,
/// the same way any other database-backed request already does.
///
/// Kept separate from the main pool so a burst of lock-holding connections
/// cannot starve ordinary request handlers. See
/// `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` for the sizing tradeoff.
pub fn lock_pool(
database_url: &str,
max_connections: u32,
acquire_timeout: Duration,
) -> Result<PgPool> {
info!(
max_connections,
acquire_timeout_secs = acquire_timeout.as_secs(),
"creating dedicated advisory-lock pool (lazy)"
);
PgPoolOptions::new()
.max_connections(max_connections)
// Explicit, and load-bearing rather than cosmetic: `RepoWriteGuard::Drop`
// has a no-runtime branch that calls `PoolConnection::leak()` and relies
// on the husk's own drop doing nothing. With `min_connections > 0` that
// drop still spawns a pool-replenish task, and spawning without a runtime
// panics inside Drop, which aborts the process during unwind. It is 0 by
// default, so this line exists to keep a future tuning change from
// silently re-arming that panic.
.min_connections(0)
.acquire_timeout(acquire_timeout)
.connect_lazy(database_url)
.context("creating advisory-lock pool")
}
/// Cheap liveness probe against the pool, for readiness checks: one
/// `SELECT 1` that fails fast when the database is unreachable.
pub async fn ping(&self) -> Result<()> {
@@ -2571,15 +2606,7 @@ impl Db {
// The read-then-write race is benign: if a row appears in between,
// this write becomes an UPDATE and the UnprovenRepoint guard below
// still refuses any http_url change.
PeerWriteAuthority::Unproven
if !sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM peers WHERE did = $1)",
)
.bind(did)
.fetch_one(&self.pool)
.await
.unwrap_or(false) =>
{
PeerWriteAuthority::Unproven if !self.peer_exists(did).await.unwrap_or(false) => {
match did.parse::<gitlawb_core::did::Did>() {
// Only reachable from the bootstrap announce-back in main.rs,
// which passes the contacted peer's raw JSON string; the
@@ -2709,6 +2736,21 @@ impl Db {
Ok(())
}
/// Whether a DID has a row in `peers`, by keyed lookup.
///
/// For callers that only need the membership answer. `list_peers` fetches
/// and materializes every row, so using it as a membership test on a hot
/// path (the gossip ingest gate) makes the cost of one event grow with the
/// size of the table.
pub async fn peer_exists(&self, did: &str) -> Result<bool> {
Ok(
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM peers WHERE did = $1)")
.bind(did)
.fetch_one(&self.pool)
.await?,
)
}
pub async fn list_peers(&self) -> Result<Vec<PeerRecord>> {
let rows = sqlx::query(
"SELECT did, http_url, last_seen, last_ping_ok, announced_at
@@ -7864,6 +7906,41 @@ mod peer_authority_tests {
db
}
/// `peer_exists` answers the membership question the gossip ingest gate
/// asks on every event, so both answers are pinned here: a registered DID
/// is true, and an unregistered one is false rather than an error or a
/// prefix match on a registered DID.
#[sqlx::test]
async fn peer_exists_answers_both_ways(pool: sqlx::PgPool) {
let db = db(pool).await;
let did = VICTIM_DID;
assert!(
!db.peer_exists(did).await.unwrap(),
"an empty peers table must answer false, not error"
);
db.upsert_peer(
did,
"https://peer.example.com",
PeerWriteAuthority::Proven(did),
)
.await
.unwrap();
assert!(db.peer_exists(did).await.unwrap());
assert!(
!db.peer_exists(OTHER_DID).await.unwrap(),
"a different DID must not match"
);
assert!(
!db.peer_exists(&VICTIM_DID[..VICTIM_DID.len() - 1])
.await
.unwrap(),
"the lookup is an equality test, not a prefix test"
);
}
/// The whole row, read back through `list_peers` rather than raw SQL, so a
/// case that claims "unchanged" is comparing every column a consumer sees.
async fn row(db: &Db, did: &str) -> Option<(String, String, Option<String>, bool, String)> {
@@ -8280,6 +8357,7 @@ mod peer_authority_tests {
/// | `a_legacy_row_can_still_refresh_its_liveness` (db/mod.rs) | test-only. Seeds a PRE-GATE row by raw SQL on purpose: `upsert_peer` cannot create one, since the gate it is testing refuses exactly that DID. The fixture models what a deployed table already holds |
/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (main.rs) | test-only fixture seed. Raw SQL because the test drives the readiness HYSTERESIS, which needs a row already at `last_ping_ok = TRUE` before the round runs; it never exercises the announce gate |
/// | `manual_ping_uses_readiness_without_mutating_federation_gate` (api/peers.rs) | test-only fixture seed, same shape and same reason: the row under test must pre-exist so the assertion is about what the ping does NOT rewrite |
/// | `seed_peer` (p2p/mod.rs) | test-only fixture seed. Raw SQL so the gossip ingest tests can drive the known-peer gate directly, including the did:web case `upsert_peer` would refuse |
///
/// And the `upsert_peer` CALL-SITE authority table, which the ledger above
/// structurally cannot hold, because the bootstrap site issues no SQL of its own
@@ -8377,6 +8455,7 @@ mod peers_table_writer_guard {
("prune_non_public_peers", 1),
("prune_self_peers", 1),
("seed_local_peer", 1),
("seed_peer", 1),
("upsert_peer", 2),
];
+122 -6
View File
@@ -69,6 +69,12 @@ fn resolve_all_recipients(dids: &BTreeSet<String>) -> Result<Vec<VerifyingKey>,
enum SealPlan {
/// An existing envelope already covers exactly this recipient set; nothing to do.
SkipUnchanged,
/// The stored tag matches, but the stored reader set contains a DID that
/// must not resolve (e.g. a small-order did:key sealed before the choke
/// point landed). The old envelope is already public and cannot be
/// withdrawn, but it must be surfaced for operator action rather than
/// silently skipped as unchanged.
SkipUnresolvableStored(Vec<String>),
/// No recipient DID resolved to a key, so there is nothing to seal to.
SkipNoRecipients,
/// At least one recipient DID is unresolvable. Fail closed: never seal to a
@@ -90,15 +96,30 @@ enum SealPlan {
/// recover the blob; reader removal is not retroactive (the old envelope is
/// already public). The comparison is on the opaque node-keyed tag, never the
/// DID list.
///
/// A matching stored tag is normally a skip, but the set is still resolved
/// before skipping: a reader that used to resolve and now must not (a
/// small-order did:key sealed before the choke point landed) is surfaced as
/// [`SealPlan::SkipUnresolvableStored`] instead of being silently treated as
/// unchanged. The old envelope is already public and cannot be withdrawn, but
/// the operator must be able to see that a persisted envelope was sealed to a
/// weak recipient.
fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&str>) -> SealPlan {
let tag = recipients_tag(node_seed, dids);
if stored_tag == Some(tag.as_str()) {
return SealPlan::SkipUnchanged;
}
match resolve_all_recipients(dids) {
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
Ok(keys) => SealPlan::Seal { keys, tag },
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
// The stored envelope already covers exactly this set, so there is
// nothing to re-seal. Resolve anyway so a now-unresolvable reader in an
// unchanged set is surfaced rather than hidden by the tag match.
match resolve_all_recipients(dids) {
Ok(_) => SealPlan::SkipUnchanged,
Err(unresolved) => SealPlan::SkipUnresolvableStored(unresolved),
}
} else {
match resolve_all_recipients(dids) {
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
Ok(keys) => SealPlan::Seal { keys, tag },
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
}
}
}
@@ -131,6 +152,21 @@ pub async fn encrypt_and_pin(
// covered (which would permanently lock out the dropped readers, #47).
let (keys, tag) = match plan_seal(node_seed, dids, stored_tag.as_deref()) {
SealPlan::SkipUnchanged => continue,
SealPlan::SkipUnresolvableStored(unresolved) => {
// A stored envelope already covers this exact reader set, and
// the set now contains a DID that must not resolve (a
// small-order did:key sealed before the choke point landed).
// The old envelope is already public and cannot be withdrawn
// or re-sealed; surface it so the operator can act.
let sample: Vec<&String> = unresolved.iter().take(3).collect();
tracing::warn!(
oid = %oid,
unresolved_count = unresolved.len(),
unresolved_sample = ?sample,
"stored envelope was sealed to a recipient that must not resolve; operator action may be required"
);
continue;
}
SealPlan::SkipNoRecipients => {
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
continue;
@@ -206,6 +242,49 @@ mod tests {
use super::*;
use ed25519_dalek::SigningKey;
/// A did:key encoding a small-order point. Well-formed, indistinguishable
/// from a real one by eye, and refused at resolution since the choke-point
/// guard landed.
fn weak_did_key() -> String {
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).expect("decompresses");
Did::from_verifying_key(&vk).to_string()
}
/// The #47 fail-closed arm already refuses to seal to a partial recipient
/// set. Because a small-order did:key no longer RESOLVES, it lands in the
/// unresolved set and that existing arm catches it: nothing is sealed, and
/// no new code path was added to make that happen.
#[test]
fn weak_recipient_falls_into_the_existing_fail_closed_arm() {
let weak = weak_did_key();
let mut dids = BTreeSet::new();
dids.insert(did_key(9));
dids.insert(weak.clone());
match plan_seal(&[0u8; 32], &dids, None) {
SealPlan::SkipUnresolvable(unresolved) => assert!(
unresolved.contains(&weak),
"the weak DID must be named in the unresolved set, got {unresolved:?}"
),
other => panic!("expected SkipUnresolvable, got {other:?}"),
}
}
/// Must-not-over-reject control: an all-legitimate recipient set still seals.
#[test]
fn legitimate_recipient_set_still_seals() {
let mut dids = BTreeSet::new();
dids.insert(did_key(9));
dids.insert(did_key(11));
match plan_seal(&[0u8; 32], &dids, None) {
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2, "both recipients resolve"),
other => panic!("expected Seal, got {other:?}"),
}
}
fn did_key(seed: u8) -> String {
let vk = SigningKey::from_bytes(&[seed; 32]).verifying_key();
Did::from_verifying_key(&vk).to_string()
@@ -348,6 +427,43 @@ mod tests {
));
}
/// P1 remediation (review): an unchanged reader set is normally a skip, but
/// the skip must not hide a recipient that now fails to resolve. A blob
/// sealed to a small-order did:key before the choke-point guard landed keeps
/// its old CID and stays decryptable-without-a-private-key unless the
/// matching-tag path surfaces it. This pins that the stored-tag match still
/// resolves the set instead of short-circuiting.
#[test]
fn plan_seal_surfaces_unresolvable_recipient_in_unchanged_reader_set() {
let weak = weak_did_key();
let mut dids = BTreeSet::new();
dids.insert(did_key(9));
dids.insert(weak.clone());
let stored = recipients_tag(&SEED, &dids);
match plan_seal(&SEED, &dids, Some(&stored)) {
SealPlan::SkipUnresolvableStored(unresolved) => assert!(
unresolved.contains(&weak),
"the weak DID must be named in the unresolved set, got {unresolved:?}"
),
other => panic!("expected SkipUnresolvableStored, got {other:?}"),
}
}
/// Degenerate control: an empty set with a matching stored tag stays a
/// silent skip. The state is unreachable in practice (a seal is never
/// recorded for an empty reader set), so this pins only that the
/// resolve-on-match path must not turn it into a re-seal or a warning.
#[test]
fn plan_seal_unchanged_empty_set_still_skips() {
let empty = BTreeSet::new();
let empty_tag = recipients_tag(&SEED, &empty);
assert!(matches!(
plan_seal(&SEED, &empty, Some(&empty_tag)),
SealPlan::SkipUnchanged
));
}
#[test]
fn plan_seal_reseals_when_recipient_set_changed() {
// A stored tag for a DIFFERENT set is a miss: a newly added reader must
+89 -2
View File
@@ -69,6 +69,15 @@ pub enum AppError {
#[error("server overloaded: {0}")]
Overloaded(String),
#[error("repository is busy")]
RepoBusy,
#[error("repository is temporarily unavailable")]
RepoUnavailable,
#[error("repository write was fenced by a concurrent publish")]
RepoWriteFenced,
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
@@ -110,7 +119,31 @@ impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
match err.downcast::<sqlx::Error>() {
Ok(sql) => AppError::Db(sql),
Err(err) => AppError::Internal(err),
// Lock contention is transient and ordinary, so it must not land as a
// 500. The internal message names the owner slug and repo, so the
// variant carries nothing: the detail stays in the log at the raise
// site and the client gets a fixed retryable body.
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
Ok(_) => AppError::RepoBusy,
// Same reasoning one rung down: a refused under-lock refresh is a
// transient storage condition, and its internal message names the
// owner slug and repo, so the variant carries nothing.
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
Ok(_) => AppError::RepoUnavailable,
// And one more rung: a publish the store refused twice is
// transient in the same way, and the retry is the client's
// to make. The variant carries nothing for the same reason
// as the two above.
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
{
Ok(_) => AppError::RepoWriteFenced,
Err(err) => AppError::Internal(err),
},
},
},
},
}
}
}
@@ -184,6 +217,29 @@ impl IntoResponse for AppError {
// 504, distinct from the 500 git_error and from the read-gate's 404 /
// the auth 401, so the client can tell a deadline from a failure.
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
// 503 with a FIXED body: the caller should retry, and must not be told
// which repo is contended or for how long.
AppError::RepoBusy => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_busy",
"repository is busy — retry".into(),
),
// 503 with a FIXED body for the same reason: the caller should retry, and
// must not be told which repo could not be refreshed or why.
AppError::RepoUnavailable => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_unavailable",
"repository is temporarily unavailable, retry".into(),
),
// 503 with a FIXED body again, and its own code: the caller should
// retry, but the condition is not contention, so a client that
// distinguishes them should be able to. The body must not say which
// repo lost its publish or to whom.
AppError::RepoWriteFenced => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_write_fenced",
"repository changed underneath this write, retry".into(),
),
AppError::Db(e) if db_unavailable(e) => (
StatusCode::SERVICE_UNAVAILABLE,
DB_UNAVAILABLE_CODE,
@@ -244,7 +300,11 @@ impl IntoResponse for AppError {
// here rather than in bespoke early returns, keeping each variant handled once.
if matches!(
self,
AppError::Overloaded(_) | AppError::SearchIncomplete { .. }
AppError::Overloaded(_)
| AppError::SearchIncomplete { .. }
| AppError::RepoBusy
| AppError::RepoUnavailable
| AppError::RepoWriteFenced
) {
resp.headers_mut().insert(
axum::http::header::RETRY_AFTER,
@@ -362,4 +422,31 @@ mod tests {
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
#[test]
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
for err in [
AppError::RepoBusy,
AppError::RepoUnavailable,
AppError::RepoWriteFenced,
] {
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
}
}
+83
View File
@@ -61,6 +61,27 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
Ok(())
}
/// Remove a single issue ref while the write guard still holds the advisory lock,
/// after a definite publish refusal. Best-effort: a failed delete leaves a
/// local-only orphan, which is still better than telling the client to retry into a
/// duplicate id. Not used on `UploadUnknowable`, where the PUT may still land.
pub fn delete_issue_ref(
git_bin: &str,
repo_path: &Path,
issue_id: &str,
deadline: std::time::Instant,
) -> Result<()> {
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
crate::git::visibility_pack::run_bounded_git(
git_bin,
&["update-ref", "-d", &ref_name],
repo_path,
b"",
deadline,
)?;
Ok(())
}
/// List all issue refs and return their JSON content.
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
// List all refs under refs/gitlawb/issues/
@@ -108,6 +129,18 @@ pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
/// Returns Ok(Some(full_id)) on unique match, Ok(None) if not found,
/// Err if the prefix is ambiguous (matches more than one issue).
pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<String>> {
// The id comes from the request path and is spliced into a git revision and a
// for-each-ref pattern below. Issue ids are UUIDs, so accept only ASCII
// alphanumerics and '-': anything else (`^{blob}`, `@{0}`, `*`, `/`, `..`) is
// revision or glob syntax, never an issue id, and is reported as not found.
if id_or_prefix.is_empty()
|| !id_or_prefix
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Ok(None);
}
// Try exact match first — fast path for callers passing the full UUID.
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
let check = Command::new("git")
@@ -249,6 +282,35 @@ mod tests {
assert_eq!(resolved, Some(full_id.to_string()));
}
#[test]
fn test_resolve_rejects_revision_syntax() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
// Each of these resolves to (or globs over) the issue blob in git, but none
// is an issue id, so the lookup must not accept it.
for crafted in [
"abc12345-0000-0000-0000-000000000000^{blob}",
"abc12345-0000-0000-0000-000000000000^{}",
"abc12345-0000-0000-0000-000000000000~0",
"*",
"",
] {
assert_eq!(
resolve_issue_id(dir.path(), crafted).unwrap(),
None,
"{crafted:?}"
);
assert_eq!(get_issue(dir.path(), crafted).unwrap(), None, "{crafted:?}");
}
}
#[test]
fn test_resolve_prefix_not_found() {
let dir = TempDir::new().unwrap();
@@ -278,6 +340,27 @@ mod tests {
assert!(result.unwrap_err().to_string().contains("ambiguous"));
}
#[test]
fn delete_issue_ref_removes_a_created_ref() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "eee88888-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"eee88888-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
delete_issue_ref(
"git",
dir.path(),
full_id,
std::time::Instant::now() + std::time::Duration::from_secs(30),
)
.unwrap();
assert_eq!(resolve_issue_id(dir.path(), full_id).unwrap(), None);
}
#[test]
fn test_close_issue_via_prefix() {
let dir = TempDir::new().unwrap();
+1
View File
@@ -1,4 +1,5 @@
pub mod issues;
pub mod publish;
pub mod push_delta;
pub mod repo_store;
pub mod smart_http;
+358
View File
@@ -0,0 +1,358 @@
//! The publication boundary: WHO wrote, and HOW FAR that write got.
//!
//! Four lifecycle defects on this branch shared one root cause. A resource was
//! tracked by a coarse state — "release started", "the path exists", "a row with
//! this name exists" — when the safety question was about the IDENTITY and the
//! PUBLICATION STAGE of one specific write attempt. A tail replicated refs from
//! an attempt that had not dispatched a PUT; a read served a tree whose
//! generation nobody had confirmed; a fork claimed a concurrent attempt's row and
//! deleted a successor's object; a response-loss failure was compensated as if it
//! proved non-publication.
//!
//! This module is the vocabulary that makes those questions answerable, and it is
//! deliberately free of any object-storage type:
//!
//! - [`PublishAttemptId`] — minted before the request is built, carried with the
//! bytes as user metadata, read back off the store to decide whether what is
//! published is THIS attempt's work.
//! - [`PublishStage`] / [`PublishStageCell`] — how far one attempt got, observable
//! from outside the future that is performing it, so a CANCELLED attempt can
//! still be classified.
//! - [`UploadError`] — what the client KNOWS about dispatch and commit, not just
//! which HTTP status came back. Destructive compensation is licensed only by
//! [`UploadError::proves_not_published`].
//!
//! # Porting note (#79)
//!
//! PR #79 (`feat/storage-abstraction`) deletes `git/tigris.rs` and replaces it
//! with a `BlobStore`/`RepoArchive` layer. Everything in this file is
//! backend-agnostic on purpose and is meant to survive that swap unchanged: a new
//! backend supplies its own error classifier (the one in `tigris.rs` is a single
//! private function) and keeps these types as the contract its callers read.
use std::sync::Mutex;
/// A durable identity for ONE publish attempt.
///
/// The point is reconciliation. A conditional PUT whose response was lost leaves
/// the client unable to say whether its bytes committed; an attempt id stored
/// alongside those bytes turns that into a question the store can answer, because
/// "is the published object mine?" is decidable where "did my request succeed?"
/// is not.
///
/// Fork creation passes the DB row id it is about to insert, so the object, the
/// on-disk clone and the database row are all stamped with the same attempt
/// identity and every cleanup can be made conditional on still owning all three.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishAttemptId(String);
impl PublishAttemptId {
/// A fresh identity for an attempt that has nothing else to be named after.
pub fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
/// Name the attempt after a caller-owned identity — fork creation uses the
/// `record.id` it is about to insert, which is what ties the object back to
/// the exact row rather than to the logical owner/name.
pub fn from_owned(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for PublishAttemptId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PublishAttemptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// The object user-metadata key an attempt id travels in.
///
/// S3 lowercases user-metadata keys, so this must already be lowercase or the
/// read back would never match what was written.
pub const ATTEMPT_METADATA_KEY: &str = "gitlawb-attempt";
/// The precondition an upload is fenced on.
///
/// Object storage is the only place a fence can hold. Dropping the future of an
/// in-flight PUT does not cancel the request the server is already processing,
/// so no amount of local locking stops an abandoned writer's bytes from landing
/// after a successor has published. A conditional PUT the store itself refuses
/// is what actually stops it.
#[derive(Clone, Debug)]
pub enum UploadPrecondition {
/// Publish only if the stored object is still the generation we observed.
IfMatch(String),
/// Publish only if nothing is stored under the key yet.
IfAbsent,
/// No fence. Last writer wins.
Unconditional,
}
/// What the store holds under a key right now.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredGeneration {
/// The generation a later conditional upload can fence itself on.
pub etag: Option<String>,
/// The attempt that published it, when the object carries our metadata.
/// `None` for anything written by something that does not stamp attempts
/// (an operator upload, a pre-attempt-id archive).
pub attempt: Option<String>,
}
impl StoredGeneration {
/// Is what the store holds the work of `attempt`?
///
/// This is the whole reconciliation primitive. It is deliberately a
/// three-way question collapsed to a boolean only at the point of use: an
/// object with no attempt metadata answers `false`, because "somebody else's
/// bytes" and "bytes nobody stamped" license exactly the same caution.
pub fn belongs_to(&self, attempt: &PublishAttemptId) -> bool {
self.attempt.as_deref() == Some(attempt.as_str())
}
}
/// The receipt of a publish the store ACKNOWLEDGED.
///
/// Only produced on a response the client actually read, so holding one is proof
/// of publication in a way that "the upload future returned" is not.
#[derive(Clone, Debug)]
pub struct UploadReceipt {
pub attempt: PublishAttemptId,
pub etag: Option<String>,
}
/// How far ONE publish attempt got.
///
/// Observable through [`PublishStageCell`] from outside the future doing the
/// work, which is the property the whole design turns on: a handler future that
/// is DROPPED never returns an outcome, so the only way to classify a cancelled
/// attempt is to read the stage it had reached when it was abandoned.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PublishStage {
/// Nothing has been attempted.
Idle,
/// No object-storage backend is configured, so there is no publication to
/// confirm and the local write IS the durable copy. Distinct from `Idle`,
/// which means an attempt was possible and had not started: a cancellation
/// under `NoBackend` loses nothing, while a cancellation under `Idle` means
/// a publish that could have happened never did.
NoBackend,
/// The archive is being built. The request has not been constructed, let
/// alone sent. A cancellation here is a DEFINITE "no publication was
/// attempted" — not an ambiguous in-flight PUT.
PreparingArchive,
/// The request is on the wire. Its fate is unknown until the store answers,
/// and a cancellation here does NOT stop the server processing it.
PutDispatched { attempt: PublishAttemptId },
/// The store acknowledged the write. This is the ONLY stage that licenses
/// replication, a 2xx, or treating the local tree as durable.
Published {
attempt: PublishAttemptId,
etag: Option<String>,
},
/// The store definitively did not publish this attempt (a refused
/// precondition, or a failure that proves the bytes never committed).
Refused,
/// The attempt may or may not have committed and has not been reconciled.
/// Destructive compensation is NEVER licensed from here.
Ambiguous { attempt: PublishAttemptId },
}
impl PublishStage {
/// Did this attempt reach a state where the store may hold its bytes?
///
/// True from dispatch onward. `false` is what makes deleting the attempt's
/// object or local tree safe.
pub fn may_have_published(&self) -> bool {
matches!(
self,
PublishStage::PutDispatched { .. }
| PublishStage::Published { .. }
| PublishStage::Ambiguous { .. }
)
}
/// The attempt whose fate is unresolved, when there is one. This is what a
/// reconciliation HEAD is compared against.
pub fn unresolved_attempt(&self) -> Option<&PublishAttemptId> {
match self {
PublishStage::PutDispatched { attempt } | PublishStage::Ambiguous { attempt } => {
Some(attempt)
}
_ => None,
}
}
}
/// A [`PublishStage`] an in-flight upload writes and an outside observer reads.
///
/// `std::sync::Mutex` rather than an async lock on purpose: every write is a
/// field assignment with no await inside, and the reader that matters most runs
/// inside a `Drop` impl, where an async lock cannot be awaited at all.
#[derive(Debug)]
pub struct PublishStageCell(Mutex<PublishStage>);
impl PublishStageCell {
pub fn new() -> Self {
Self::seeded(PublishStage::Idle)
}
pub fn seeded(stage: PublishStage) -> Self {
Self(Mutex::new(stage))
}
pub fn set(&self, stage: PublishStage) {
*self.0.lock().expect("publish stage mutex poisoned") = stage;
}
pub fn get(&self) -> PublishStage {
self.0.lock().expect("publish stage mutex poisoned").clone()
}
}
impl Default for PublishStageCell {
fn default() -> Self {
Self::new()
}
}
/// What the client knows about whether a request it could not complete ever
/// reached the wire.
///
/// The classifier that produces this is the ONLY backend-aware code in the
/// publish path; a pluggable store (#79) supplies its own and every caller below
/// keeps working off [`UploadError`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DispatchKnowledge {
/// The request could not be built or sent. The store cannot hold these bytes.
NeverSent,
/// The request may have been sent, and may have committed.
MaybeSent,
}
/// Why an upload did not publish, split by what it leaves the caller ENTITLED TO
/// DO — not by which HTTP status came back.
///
/// The split exists because the previous shape ("precondition lost" vs "some
/// other error") made every caller treat a lost response as proof of failure.
/// Smithy timeout, dispatch and response errors all allow that the request was
/// sent and committed: a server can accept a complete conditional PUT and lose
/// or corrupt the response before the client observes success. Compensating that
/// as a definite failure deletes state that IS published.
#[derive(Debug, thiserror::Error)]
pub enum UploadError {
/// The store explicitly refused the fence (412, or 409 under create-only).
/// DEFINITE: this attempt did not publish, and a successor did.
#[error("upload precondition lost (HTTP {status})")]
PreconditionLost { status: u16 },
/// The attempt provably never committed: it failed before the request could
/// be dispatched, or the store answered a definite client-side refusal.
/// Destructive compensation is safe here and ONLY here.
#[error("upload did not reach the store: {0:#}")]
NotPublished(#[source] anyhow::Error),
/// The request may have been dispatched and may have committed; the client
/// never learned which. The attempt id is carried so the caller can ask the
/// store rather than guess.
#[error("upload outcome is unknowable (attempt {attempt}): {source:#}")]
Ambiguous {
attempt: PublishAttemptId,
#[source]
source: anyhow::Error,
},
}
impl UploadError {
/// May this caller destroy state that would be needed if the write HAD
/// landed — delete the object, drop the only local clone, invalidate the
/// cache?
///
/// Only a proven non-publication says yes. Every new variant must default to
/// `false`, which is why this is a match on the safe arms rather than a
/// negation of the unsafe one.
pub fn proves_not_published(&self) -> bool {
matches!(
self,
UploadError::PreconditionLost { .. } | UploadError::NotPublished(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_definite_outcomes_license_destructive_compensation() {
assert!(UploadError::PreconditionLost { status: 412 }.proves_not_published());
assert!(
UploadError::NotPublished(anyhow::anyhow!("no route to host")).proves_not_published()
);
assert!(
!UploadError::Ambiguous {
attempt: PublishAttemptId::new(),
source: anyhow::anyhow!("response body truncated"),
}
.proves_not_published(),
"a response-loss failure must never license deleting state the write may own"
);
}
#[test]
fn a_stage_may_have_published_only_from_dispatch_onward() {
let attempt = PublishAttemptId::new();
assert!(!PublishStage::Idle.may_have_published());
assert!(!PublishStage::NoBackend.may_have_published());
assert!(
!PublishStage::PreparingArchive.may_have_published(),
"compression has not constructed a request, let alone sent one"
);
assert!(!PublishStage::Refused.may_have_published());
assert!(PublishStage::PutDispatched {
attempt: attempt.clone()
}
.may_have_published());
assert!(PublishStage::Ambiguous {
attempt: attempt.clone()
}
.may_have_published());
assert!(PublishStage::Published {
attempt,
etag: None
}
.may_have_published());
}
#[test]
fn an_unstamped_object_never_belongs_to_an_attempt() {
let attempt = PublishAttemptId::new();
assert!(StoredGeneration {
etag: Some("\"e\"".into()),
attempt: Some(attempt.as_str().to_string()),
}
.belongs_to(&attempt));
assert!(
!StoredGeneration {
etag: Some("\"e\"".into()),
attempt: None,
}
.belongs_to(&attempt),
"bytes nobody stamped must not be claimed by this attempt"
);
assert!(!StoredGeneration {
etag: Some("\"e\"".into()),
attempt: Some("someone-else".into()),
}
.belongs_to(&attempt));
}
}
+36 -11
View File
@@ -19,7 +19,7 @@
//! objects the reachable withheld set never classified, so subtracting that set
//! is not enough — a dangling private blob would slip through (#99). The caller
//! signals a full scan via [`PinCandidateSet::full_scan`] and must then apply
//! the fail-closed blob filter (`visibility_pack::replicable_objects_fail_closed`)
//! the fail-closed blob-and-tree filter (`visibility_pack::replicable_objects_fail_closed`)
//! instead of the plain reachable-only subtraction.
//!
//! Because the pin candidate set needs only the OID *set* (never the per-path
@@ -211,8 +211,9 @@ pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> R
/// Like [`list_all_objects`] but pairs each OID with its object type, via
/// `--batch-check='%(objectname) %(objecttype)'`. The pin path's fail-closed
/// filter needs to tell blobs (content, withholdable) from commits/trees
/// (structural, never withheld) without typing the candidate list itself.
/// filter needs to tell blobs (content, withholdable) and trees (content,
/// withholdable since #135/#173) from commits/tags (structural, never withheld)
/// without typing the candidate list itself.
pub fn list_all_objects_with_type(
repo_path: &Path,
git_bin: &str,
@@ -244,24 +245,48 @@ pub fn list_all_objects_with_type(
/// Every blob OID in the repo, including unreachable/dangling ones. The
/// fail-closed pin filter drops any candidate blob absent from the reachable,
/// visibility-allowed set; a dangling private blob is in this set but not the
/// allowed set, so it never replicates (#99).
/// allowed set, so it never replicates (#99). Production uses
/// [`all_blob_and_tree_oids`] (one typed pass); this wrapper stays for tests.
#[cfg(test)]
pub fn all_blob_oids(
repo_path: &Path,
git_bin: &str,
deadline: Instant,
) -> Result<HashSet<String>> {
Ok(list_all_objects_with_type(repo_path, git_bin, deadline)?
.into_iter()
.filter(|(_, ty)| ty == "blob")
.map(|(oid, _)| oid)
.collect())
Ok(all_blob_and_tree_oids(repo_path, git_bin, deadline)?.0)
}
/// Every blob OID and every tree OID in the repo, including unreachable/dangling
/// ones, from one typed enumeration. The fail-closed pin filter drops any
/// candidate blob or tree absent from the matching reachable visibility-allowed
/// set; a dangling or withheld object is in this universe but not the allowed
/// set, so it never replicates (#99, #172).
pub fn all_blob_and_tree_oids(
repo_path: &Path,
git_bin: &str,
deadline: Instant,
) -> Result<(HashSet<String>, HashSet<String>)> {
let mut blobs = HashSet::new();
let mut trees = HashSet::new();
for (oid, ty) in list_all_objects_with_type(repo_path, git_bin, deadline)? {
match ty.as_str() {
"blob" => {
blobs.insert(oid);
}
"tree" => {
trees.insert(oid);
}
_ => {}
}
}
Ok((blobs, trees))
}
/// The pin candidate OIDs for a push plus whether they came from a full-repo
/// scan. `full_scan` is true when the delta could not be used and the whole
/// object DB (including dangling objects) was enumerated — the caller must then
/// apply the fail-closed blob filter, because the reachable-only withheld set
/// cannot classify dangling blobs (#99).
/// apply the fail-closed blob-and-tree filter, because the reachable-only withheld set
/// cannot classify dangling blobs or trees (#99, #172).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinCandidateSet {
pub candidates: Vec<String>,
File diff suppressed because it is too large. Load diff
+26 -6
View File
@@ -106,7 +106,7 @@ pub async fn info_refs(
let mut command = Command::new(git_bin);
command
.arg(service_to_command(service))
.arg(service_to_command(service)?)
.arg("--stateless-rpc")
.arg("--advertise-refs")
.arg(repo_path);
@@ -426,7 +426,7 @@ async fn run_git_service(
) -> Result<Vec<u8>> {
let mut command = Command::new(git_bin);
command
.arg(service_to_command(service))
.arg(service_to_command(service)?)
.arg("--stateless-rpc")
.arg(repo_path);
let (out, admission) = drive_git_child(command, input, timeout, service, admission).await?;
@@ -578,11 +578,13 @@ async fn drive_git_child(
Ok((out, admission))
}
fn service_to_command(service: &str) -> &str {
/// Map a validated service name to its git subcommand. Only the two fixed
/// subcommands can come out, so an unrecognized service can never reach git's argv.
fn service_to_command(service: &str) -> Result<&'static str> {
match service {
"git-upload-pack" => "upload-pack",
"git-receive-pack" => "receive-pack",
_ => service,
"git-upload-pack" => Ok("upload-pack"),
"git-receive-pack" => Ok("receive-pack"),
other => bail!("unknown git service: {other}"),
}
}
@@ -866,6 +868,24 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
#[test]
fn service_to_command_maps_allowlist_and_rejects_passthrough() {
assert_eq!(
service_to_command("git-upload-pack").unwrap(),
"upload-pack"
);
assert_eq!(
service_to_command("git-receive-pack").unwrap(),
"receive-pack"
);
for bad in ["git-fetch-pack", "--version", "upload-pack", ""] {
assert!(
service_to_command(bad).is_err(),
"must reject passthrough for {bad:?}"
);
}
}
/// List OIDs in a pack by writing it to a temp dir and running verify-pack.
pub(super) fn pack_object_ids(pack: &[u8]) -> std::collections::HashSet<String> {
let dir = TempDir::new().unwrap();
+576 -22
View File
@@ -1,5 +1,5 @@
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::path::Path;
use std::process::Command;
/// Initialize a new bare git repository with SHA-1 object format (default).
@@ -746,13 +746,149 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
Ok(Some((obj_type, content)))
}
/// Outcome of validating a stored branch-field value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitRefValidationError {
/// Caller supplied a name that is not a valid local branch name.
Invalid(String),
/// Git could not be executed to validate the name.
GitUnavailable(String),
}
impl std::fmt::Display for GitRefValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(msg) | Self::GitUnavailable(msg) => f.write_str(msg),
}
}
}
/// Reject revision shorthands and pseudoref names that `git check-ref-format
/// --branch` accepts but that git later interprets as symbolic revisions rather
/// than literal local branch names.
fn reject_revision_shorthand(name: &str) -> Option<&'static str> {
if name == "@" {
return Some("branch ref must not be a revision shorthand");
}
if name.starts_with("@{") {
return Some("branch ref must not be a reflog revision expression");
}
const PSEUDOREFS: &[&str] = &[
"FETCH_HEAD",
"ORIG_HEAD",
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"RERERE_MERGE_HEAD",
"REBASE_HEAD",
"REVERT_HEAD",
"BISECT_HEAD",
"AUTO_MERGE",
];
if PSEUDOREFS.contains(&name) {
return Some("branch ref must not be a git pseudoref name");
}
None
}
/// Validate a git *branch name* before it is stored or passed to git subprocesses.
/// Delegates to `git check-ref-format --branch`, then rejects fully qualified
/// ref paths, revision-namespace shorthands (`heads/`, `tags/`, `remotes/`), and
/// symbolic names that git would reinterpret at the sink.
///
/// At diff/merge sinks, validated short names are passed as `refs/heads/{name}` so
/// grammar-valid revision shorthands cannot retarget another ref namespace. The
/// merge worktree checks out the local branch name instead, so merge commits
/// advance refs/heads/{target}. Symbolic revision names (`HEAD`), option-shaped
/// trailing dots, and other invalid forms are rejected by check-ref-format itself.
/// Revision shorthands that pass the grammar check (`@`, `@{-1}`, pseudorefs) are
/// rejected explicitly because git treats them as symbolic revisions when bare.
///
/// Storage boundaries call this via [`validate_git_ref_with_git`] with the configured
/// git binary. Sink functions call [`validate_git_ref`] (system `git`) then prefix
/// before building argv, so the property holds for every caller and every row,
/// including legacy rows and any writer that skipped the boundary check.
pub fn validate_git_ref(name: &str) -> std::result::Result<(), GitRefValidationError> {
validate_git_ref_with_git("git", name)
}
pub(crate) fn validate_git_ref_with_git(
git_bin: &str,
name: &str,
) -> std::result::Result<(), GitRefValidationError> {
if name.is_empty() {
return Err(GitRefValidationError::Invalid(
"branch ref must not be empty".into(),
));
}
if name.starts_with("refs/") {
return Err(GitRefValidationError::Invalid(
"branch ref must be a local branch name, not a fully qualified ref".into(),
));
}
if name.starts_with("heads/") || name.starts_with("tags/") || name.starts_with("remotes/") {
return Err(GitRefValidationError::Invalid(
"branch ref must be a local branch name, not a revision shorthand".into(),
));
}
if let Some(reason) = reject_revision_shorthand(name) {
return Err(GitRefValidationError::Invalid(reason.into()));
}
// allow-unbounded-git: stateless check-ref-format on a user-supplied name; no repo acquire or concurrency permit
let output = Command::new(git_bin)
.args(["check-ref-format", "--branch", name])
.output()
.map_err(|e| {
// allow-unbounded-git: stateless check-ref-format on user-supplied name; no repo acquire
GitRefValidationError::GitUnavailable(format!(
"failed to run git check-ref-format: {e}"
))
})?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(GitRefValidationError::Invalid(stderr.trim().to_string()))
}
}
/// Reject both refs at the sink, so an option-shaped ref can never reach a git
/// argv element regardless of how it was stored.
fn map_ref_validation_to_anyhow(which: &str, err: GitRefValidationError) -> anyhow::Error {
match err {
GitRefValidationError::Invalid(msg) => {
anyhow::anyhow!("invalid {which} branch ref: {msg}")
}
GitRefValidationError::GitUnavailable(msg) => anyhow::anyhow!("{msg}"),
}
}
fn guard_refs(target_branch: &str, source_branch: &str) -> Result<()> {
validate_git_ref(target_branch).map_err(|e| map_ref_validation_to_anyhow("target", e))?;
validate_git_ref(source_branch).map_err(|e| map_ref_validation_to_anyhow("source", e))?;
Ok(())
}
/// Stored short branch names are passed to git as explicit local-branch refs so a
/// syntax-valid name cannot be reinterpreted as a revision shorthand at the sink.
fn local_branch_ref(short_name: &str) -> String {
format!("refs/heads/{short_name}")
}
/// Get the diff between two branches: changes on source_branch not in target_branch.
pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -> Result<String> {
guard_refs(target_branch, source_branch)?;
let target = local_branch_ref(target_branch);
let source = local_branch_ref(source_branch);
let output = Command::new("git")
.args(["diff", &format!("{target_branch}...{source_branch}")])
.args(["diff", &format!("{target}...{source}")])
.current_dir(repo_path)
.output()
.context("failed to run git diff")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git diff failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
@@ -765,13 +901,11 @@ pub fn branch_diff_names(
target_branch: &str,
source_branch: &str,
) -> Result<Vec<String>> {
guard_refs(target_branch, source_branch)?;
let target = local_branch_ref(target_branch);
let source = local_branch_ref(source_branch);
let output = Command::new("git")
.args([
"diff",
"--name-only",
"-z",
&format!("{target_branch}...{source_branch}"),
])
.args(["diff", "--name-only", "-z", &format!("{target}...{source}")])
.current_dir(repo_path)
.output()
.context("failed to run git diff --name-only")?;
@@ -799,18 +933,78 @@ pub fn merge_branch(
author_did: &str,
pr_title: &str,
) -> Result<String> {
guard_refs(target_branch, source_branch)?;
let source_ref = local_branch_ref(source_branch);
let target_ref = local_branch_ref(target_branch);
let worktree_path = repo_path.join("_merge_worktree");
// Clean up any leftover worktree
if worktree_path.exists() {
let remove_worktree = || {
// Two --force flags, not one. A single --force refuses a LOCKED worktree
// ("cannot remove a locked working tree") and `git worktree prune` skips
// locked registrations too, so with one flag a locked leftover survives
// the whole cleanup and wedges this endpoint for good. With both flags a
// separate prune adds nothing: over every leftover state that can reach
// here (registered/unregistered x directory present/gone/locked/corrupt)
// `remove -f -f` plus the removal below already clears the registration.
let _ = Command::new("git")
.args(["worktree", "remove", "--force", "_merge_worktree"])
.args([
"worktree",
"remove",
"--force",
"--force",
"_merge_worktree",
])
.current_dir(repo_path)
.output();
let _ = std::fs::remove_dir_all(&worktree_path);
};
// Clean up any leftover worktree. This cannot be gated on the directory
// existing: a registration whose directory is gone is the one state
// `worktree add` refuses outright ("missing but already registered"), and
// it is reachable from the cleanup above, so gating on .exists() would let
// one failed removal wedge every later merge on this repo.
remove_worktree();
// The merge must land on the LOCAL target branch, so the worktree has to end
// up attached to exactly refs/heads/{target}. validate_git_ref proves
// branch-name grammar only: it does not prove refs/heads/{target} exists,
// and it cannot stop git's revision DWIM rules from resolving the same
// short name in another namespace (refs/tags/{name}, refs/remotes/...). If
// DWIM won, `worktree add` would check out a detached HEAD at the foreign
// ref, the merge would succeed on that disposable HEAD, and cleanup would
// discard the commit without ever advancing refs/heads/{target}.
//
// (1) Require the exact local ref before creating the worktree. show-ref
// --verify matches the full refname literally — no DWIM, no abbreviation —
// so a same-named tag with no local branch fails here instead of being
// silently resolved.
//
// allow-unbounded-git: read-only `show-ref --verify` on one literal refname,
// no network and no object walk. It also sits alongside the pre-existing bare
// `git diff`, `git merge` and `git rev-parse` spawns in this same function,
// all under the same acquire_write lock, so bounding only this one would not
// change the function's exposure. Bounding merge_branch as a whole is a
// separate change against already-reviewed code, tracked as follow-up.
let target_exists = Command::new("git")
.args(["show-ref", "--verify", "--quiet", &target_ref])
.current_dir(repo_path)
.output()
.context("failed to run git show-ref")?;
if !target_exists.status.success() {
bail!("target branch {target_ref} does not exist as a local branch");
}
// Create worktree on target branch
// (2) Check out the local target branch in the worktree. The bare short
// name is what makes git attach HEAD to the branch (passing
// refs/heads/{name} would detach, so a successful merge would not advance
// refs/heads/{target}); step (1) guarantees the branch exists, so an
// attached checkout is the only acceptable outcome — verified right below.
//
// allow-unbounded-git: local `worktree add`, no network. Same reasoning as the
// show-ref spawn above: the surrounding function's other git children are
// already bare under this lock, so this one is not the thing that would need
// bounding first.
let wt = Command::new("git")
.args(["worktree", "add", "_merge_worktree", target_branch])
.current_dir(repo_path)
@@ -823,12 +1017,31 @@ pub fn merge_branch(
);
}
// The worktree's HEAD must be a symbolic ref to exactly refs/heads/{target}.
// A detached or differently-attached HEAD means git resolved the name as
// something other than the local target branch; a merge committed on it
// would be thrown away by cleanup, so refuse before merging rather than
// relying on any denylist of symbolic shapes.
//
// allow-unbounded-git: read-only `symbolic-ref` resolving one local HEAD, no
// network. Same reasoning as the two spawns above.
let head_ref = Command::new("git")
.args(["symbolic-ref", "--quiet", "HEAD"])
.current_dir(&worktree_path)
.output()
.context("failed to run git symbolic-ref")?;
let head_ref_name = String::from_utf8_lossy(&head_ref.stdout).trim().to_string();
if !head_ref.status.success() || head_ref_name != target_ref {
remove_worktree();
bail!("merge worktree is not attached to {target_ref} (HEAD is {head_ref_name:?})");
}
// Run merge in worktree
let merge = Command::new("git")
.args([
"merge",
"--no-ff",
source_branch,
&source_ref,
"-m",
&format!(
"Merge branch '{}' into {} ({})",
@@ -846,11 +1059,7 @@ pub fn merge_branch(
let success = merge.status.success();
// Always remove worktree
let _ = Command::new("git")
.args(["worktree", "remove", "--force", "_merge_worktree"])
.current_dir(repo_path)
.output();
let _ = std::fs::remove_dir_all(&worktree_path);
remove_worktree();
if !success {
bail!(
@@ -859,18 +1068,42 @@ pub fn merge_branch(
);
}
// Get new HEAD of target branch
// (3) The merge only counts if the exact target ref now points at a commit.
// Plain `rev-parse` echoes an unresolvable token back on stdout with a
// nonzero exit status, so an unchecked call here would hand the literal
// string "refs/heads/{target}" upward as an apparently valid merge SHA and
// the caller would mark the PR merged and fire the webhook. --verify plus
// ^{commit} plus an explicit status check turn "target ref missing or not a
// commit" into a hard failure before any of that happens.
let head = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{target_branch}")])
.args(["rev-parse", "--verify", &format!("{target_ref}^{{commit}}")])
.current_dir(repo_path)
.output()
.context("failed to get merge commit")?;
if !head.status.success() {
bail!(
"merge completed but {target_ref} does not resolve to a commit: {}",
String::from_utf8_lossy(&head.stderr)
);
}
Ok(String::from_utf8_lossy(&head.stdout).trim().to_string())
}
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
///
/// UNVALIDATED, and test-only for that reason. It performs the bare join with no
/// allowlist, no containment check and no component walk, so it accepts an empty
/// or traversal-bearing name. Production code must use
/// `git::repo_store::validated_repo_disk_path`; the last production caller was
/// `fork_repo`, which used this and turned a `{"name":""}` request into a repo
/// row at `<repos_dir>/<owner_slug>/.git`. The `cfg(test)` is the guard: a new
/// production caller fails to compile rather than silently skipping the barrier.
///
/// Tests keep it because a fixture that must escape `repos_dir` cannot be built
/// with the validated form.
#[cfg(test)]
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> std::path::PathBuf {
// Sanitize the DID for use as a directory name
let owner_slug = owner_did.replace([':', '/'], "_");
repos_dir.join(owner_slug).join(format!("{repo_name}.git"))
@@ -878,7 +1111,73 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
#[cfg(test)]
mod tests {
use super::branch_diff_names;
use super::{validate_git_ref, validate_git_ref_with_git, GitRefValidationError};
#[test]
fn validate_git_ref_accepts_normal_branch_names() {
for good in [
"main",
"feature/foo",
"release-1.2",
"v1.0.0",
"user/fix-bug",
"feature@",
"user@host",
] {
assert!(
validate_git_ref(good).is_ok(),
"{good:?} should be accepted"
);
}
}
#[test]
fn validate_git_ref_rejects_option_injection_and_malformed_refs() {
for bad in [
"",
"HEAD",
"@",
"@{-1}",
"FETCH_HEAD",
"MERGE_HEAD",
"feature.",
"feature/x.",
"refs/heads/main",
"refs/tags/v1",
"refs/heads/--output=/tmp/x",
"heads/main",
"tags/v1",
"remotes/origin/main",
"--output=/tmp/x",
"-rf",
"a b",
"a..b",
"a~b",
"refs/heads/@{x}",
"foo.lock",
"/leading",
"trailing/",
"a//b",
] {
let err = validate_git_ref(bad).expect_err("{bad:?} should be rejected");
assert!(
matches!(err, GitRefValidationError::Invalid(_)),
"{bad:?} should be an invalid-name rejection, got {err:?}"
);
}
}
#[test]
fn validate_git_ref_spawn_failure_is_git_unavailable_not_invalid() {
let err = validate_git_ref_with_git("/nonexistent/gitlawb-git", "main")
.expect_err("missing git binary should fail");
assert!(
matches!(err, GitRefValidationError::GitUnavailable(_)),
"spawn failure must not be classified as invalid input, got {err:?}"
);
}
use super::{branch_diff_names, merge_branch};
use std::path::Path;
use std::process::Command;
@@ -2025,4 +2324,259 @@ mod tests {
"a clean `missing` twice on a readable store is a genuine absence; got {res:?}"
);
}
/// CodeRabbit #379: `merge_branch`'s leftover-worktree cleanup ran only when the
/// `_merge_worktree` DIRECTORY still existed. A directory that is gone while
/// `$GIT_DIR/worktrees/_merge_worktree` still holds its registration is precisely
/// the state `git worktree add` refuses ("is a missing but already registered
/// worktree", exit 128), and it is self-inflicted: `remove_worktree` deletes the
/// directory even when the `git worktree remove` before it failed. Once a repo is
/// in that state every later merge on it fails permanently. REVERT PROOF (RED):
/// put the cleanup call back behind `if worktree_path.exists()` and this test
/// fails at the merge below with "missing but already registered".
#[test]
fn merge_branch_recovers_from_a_stale_worktree_registration() {
let td = tempfile::TempDir::new().unwrap();
let work = td.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let g = |args: &[&str], dir: &Path| {
assert!(
Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
g(&["init", "-q"], &work);
g(&["config", "user.email", "t@t"], &work);
g(&["config", "user.name", "t"], &work);
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
g(&["add", "."], &work);
g(&["commit", "-qm", "base"], &work);
let main = {
let o = Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
g(&["checkout", "-q", "-b", "feature"], &work);
std::fs::write(work.join("feat.txt"), b"feat\n").unwrap();
g(&["add", "."], &work);
g(&["commit", "-qm", "feat"], &work);
g(&["checkout", "-q", &main], &work);
let bare = td.path().join("bare.git");
g(
&[
"clone",
"--bare",
"-q",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
// Reproduce the stuck state exactly: register the worktree, then delete its
// directory without unregistering it. This is what a failed `worktree remove`
// followed by the unconditional `remove_dir_all` leaves behind.
//
// `gc.worktreePruneExpire=never` is set on purpose. Recovery must not depend on
// git's prune expiry policy: a registration this fresh would sit inside any
// sane expiry window, so a cleanup that only pruned on expiry would leave the
// repo wedged. Both `worktree remove --force` and `worktree prune` unregister a
// worktree whose gitdir target is gone regardless of this setting, and pinning
// it here keeps that true if either call is ever changed.
g(&["config", "gc.worktreePruneExpire", "never"], &bare);
g(&["worktree", "add", "_merge_worktree", &main], &bare);
std::fs::remove_dir_all(bare.join("_merge_worktree")).unwrap();
assert!(
bare.join("worktrees/_merge_worktree").exists(),
"fixture must leave the registration behind"
);
let before = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{main}")])
.current_dir(&bare)
.output()
.unwrap();
let before = String::from_utf8_lossy(&before.stdout).trim().to_string();
let merged = merge_branch(&bare, &main, "feature", "did:key:zTest", "t")
.expect("a stale worktree registration must not wedge the merge endpoint");
let after = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{main}")])
.current_dir(&bare)
.output()
.unwrap();
let after = String::from_utf8_lossy(&after.stdout).trim().to_string();
assert_ne!(before, after, "the merge must advance refs/heads/{main}");
assert_eq!(after, merged, "the returned sha must be the new branch tip");
}
/// Companion to the stale-registration case: the leftover worktree is also LOCKED.
/// `git worktree remove --force` (ONE flag) refuses a locked worktree outright, and
/// `git worktree prune` skips locked registrations too, so with a single --force the
/// cleanup makes no progress and `worktree add` keeps failing. REVERT PROOF (RED):
/// drop either --force from remove_worktree and this test fails.
#[test]
fn merge_branch_recovers_from_a_locked_stale_worktree() {
let td = tempfile::TempDir::new().unwrap();
let work = td.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let g = |args: &[&str], dir: &Path| {
assert!(
Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
g(&["init", "-q"], &work);
g(&["config", "user.email", "t@t"], &work);
g(&["config", "user.name", "t"], &work);
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
g(&["add", "."], &work);
g(&["commit", "-qm", "base"], &work);
let main = {
let o = Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
g(&["checkout", "-q", "-b", "feature"], &work);
std::fs::write(work.join("feat.txt"), b"feat\n").unwrap();
g(&["add", "."], &work);
g(&["commit", "-qm", "feat"], &work);
g(&["checkout", "-q", &main], &work);
let bare = td.path().join("bare.git");
g(
&[
"clone",
"--bare",
"-q",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
g(&["worktree", "add", "_merge_worktree", &main], &bare);
g(&["worktree", "lock", "_merge_worktree"], &bare);
std::fs::remove_dir_all(bare.join("_merge_worktree")).unwrap();
let before = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{main}")])
.current_dir(&bare)
.output()
.unwrap();
let before = String::from_utf8_lossy(&before.stdout).trim().to_string();
let merged = merge_branch(&bare, &main, "feature", "did:key:zTest", "t")
.expect("a locked leftover worktree must not wedge the merge endpoint");
let after = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{main}")])
.current_dir(&bare)
.output()
.unwrap();
let after = String::from_utf8_lossy(&after.stdout).trim().to_string();
assert_ne!(before, after, "the merge must advance refs/heads/{main}");
assert_eq!(after, merged, "the returned sha must be the new branch tip");
}
/// The third leftover shape: a `_merge_worktree` DIRECTORY with no registration
/// behind it, which is what a crash between `worktree add` creating the directory
/// and git recording it leaves. `git worktree remove` refuses this one outright
/// ("is not a working tree"), so the `remove_dir_all` in remove_worktree is the
/// only thing that clears it and `worktree add` otherwise fails on the existing
/// path. REVERT PROOF (RED): drop the remove_dir_all from remove_worktree and this
/// test fails.
#[test]
fn merge_branch_recovers_from_an_unregistered_leftover_directory() {
let td = tempfile::TempDir::new().unwrap();
let work = td.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let g = |args: &[&str], dir: &Path| {
assert!(
Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
g(&["init", "-q"], &work);
g(&["config", "user.email", "t@t"], &work);
g(&["config", "user.name", "t"], &work);
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
g(&["add", "."], &work);
g(&["commit", "-qm", "base"], &work);
let main = {
let o = Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
g(&["checkout", "-q", "-b", "feature"], &work);
std::fs::write(work.join("feat.txt"), b"feat\n").unwrap();
g(&["add", "."], &work);
g(&["commit", "-qm", "feat"], &work);
g(&["checkout", "-q", &main], &work);
let bare = td.path().join("bare.git");
g(
&[
"clone",
"--bare",
"-q",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
// A directory in the way, with nothing registered behind it.
std::fs::create_dir_all(bare.join("_merge_worktree")).unwrap();
std::fs::write(bare.join("_merge_worktree/leftover.txt"), b"junk\n").unwrap();
assert!(
!bare.join("worktrees/_merge_worktree").exists(),
"fixture must leave no registration, only the directory"
);
let before = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{main}")])
.current_dir(&bare)
.output()
.unwrap();
let before = String::from_utf8_lossy(&before.stdout).trim().to_string();
let merged = merge_branch(&bare, &main, "feature", "did:key:zTest", "t")
.expect("an unregistered leftover directory must not wedge the merge endpoint");
let after = Command::new("git")
.args(["rev-parse", &format!("refs/heads/{main}")])
.current_dir(&bare)
.output()
.unwrap();
let after = String::from_utf8_lossy(&after.stdout).trim().to_string();
assert_ne!(before, after, "the merge must advance refs/heads/{main}");
assert_eq!(after, merged, "the returned sha must be the new branch tip");
}
}
+769 -69
View File
@@ -5,17 +5,121 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context, Result};
use aws_sdk_s3::Client as S3Client;
use tracing::{debug, info};
use tracing::{debug, info, warn};
// The publication vocabulary lives in `git::publish`, which carries no
// object-storage types, and is re-exported here so existing `git::tigris::…`
// imports keep resolving. #79 deletes this file; `git::publish` is what survives
// the swap, and the only backend-aware code below is `classify_dispatch`.
pub use super::publish::{
DispatchKnowledge, PublishAttemptId, PublishStage, PublishStageCell, StoredGeneration,
UploadError, UploadPrecondition, UploadReceipt, ATTEMPT_METADATA_KEY,
};
/// What happened to a conditional, attempt-guarded delete.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttemptDelete {
/// The object was this attempt's and is gone.
Deleted,
/// Nothing is stored under the key.
Absent,
/// Something is stored, and it is NOT this attempt's work. Left alone.
NotOurs,
}
/// THE ONLY BACKEND-AWARE CLASSIFIER in the publish path.
///
/// It answers one question: does this failure PROVE the request never committed?
/// Anything short of proof is [`DispatchKnowledge::MaybeSent`], because the
/// caller's next move on a definite failure is destructive (delete the object,
/// drop the only local clone, invalidate the cache) and being wrong there
/// destroys published state.
///
/// - An HTTP status means the server answered. A 4xx is a refusal it took
/// BEFORE storing anything, so it proves non-publication. A 5xx does not: the
/// store can commit and then fail to report it.
/// - No status at all means no response was read. Only a construction failure
/// proves the request never left; Smithy's timeout, dispatch and response
/// errors all allow that the bytes arrived and committed while the answer was
/// lost, corrupted, or never parsed.
/// - `SdkError` is `#[non_exhaustive]`, so the fallback arm must be the CAUTIOUS
/// one. A future variant defaulting to "definitely failed" would silently
/// re-open exactly the class this closes.
///
/// #79 replaces `tigris.rs` with a `BlobStore` layer; this function is the piece
/// each backend reimplements, and nothing above it changes.
fn classify_put_failure<E, R>(
err: &aws_sdk_s3::error::SdkError<E, R>,
status: Option<u16>,
) -> DispatchKnowledge {
if let Some(status) = status {
return if (400..500).contains(&status) {
DispatchKnowledge::NeverSent
} else {
DispatchKnowledge::MaybeSent
};
}
match err {
aws_sdk_s3::error::SdkError::ConstructionFailure(_) => DispatchKnowledge::NeverSent,
_ => DispatchKnowledge::MaybeSent,
}
}
/// Wrapper around the S3 client with the configured bucket.
#[derive(Clone)]
pub struct TigrisClient {
s3: S3Client,
bucket: String,
/// Test-only seam: when set, the blocking compression inside `upload_tracked`
/// parks on this barrier. That is the ONLY window in which a publish attempt
/// can be cancelled with `PublishStage::PreparingArchive` still holding, and
/// it is unreachable from outside without a seam because compression of a
/// test-sized repo finishes in microseconds.
#[cfg(test)]
compress_gate: Option<Arc<BlockingGate>>,
}
/// A gate a BLOCKING thread parks on until a test opens it.
///
/// A condvar rather than a held `MutexGuard`: the test's assertions run while the
/// gate is shut, and holding a `std::sync::MutexGuard` across those awaits is
/// both a lint violation and a real hazard. Here the test owns no guard at all —
/// it flips a flag and notifies.
#[cfg(test)]
#[derive(Default)]
pub struct BlockingGate {
open: Mutex<bool>,
opened: std::sync::Condvar,
}
#[cfg(test)]
impl BlockingGate {
/// A gate that starts SHUT.
pub fn shut() -> Self {
Self::default()
}
fn wait(&self) {
let mut open = self.open.lock().expect("compression gate poisoned");
while !*open {
open = self
.opened
.wait(open)
.expect("compression gate poisoned while waiting");
}
}
/// Let every parked compression through. Call this at teardown so the
/// blocking thread is not stranded for the life of the process.
pub fn open(&self) {
*self.open.lock().expect("compression gate poisoned") = true;
self.opened.notify_all();
}
}
impl TigrisClient {
@@ -28,28 +132,48 @@ impl TigrisClient {
Ok(Self {
s3,
bucket: bucket.to_string(),
#[cfg(test)]
compress_gate: None,
})
}
/// Test-only constructor with an explicit S3 endpoint, region, and static
/// credentials — no env-var reads, so parallel tests cannot race each other's
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
/// the client at a non-routable endpoint to exercise acquire-stall paths.
/// Build a client pointed at an arbitrary endpoint, for tests.
///
/// The production constructor reads the endpoint and credentials from the
/// environment, which a test cannot steer without mutating process-global
/// state. This takes both explicitly so a test can aim the client at a
/// closed port and get a prompt transport error out of `exists()`.
///
/// `RetryConfig::disabled()` is load-bearing, not tidiness: the SDK's default
/// policy retries a connection refusal with backoff, which turns each failing
/// call into seconds of waiting.
#[cfg(test)]
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.endpoint_url(endpoint_url)
.region(aws_config::Region::new("auto"))
.credentials_provider(creds)
.load()
.await;
pub fn for_testing_with_endpoint(bucket: &str, endpoint: &str) -> Self {
use aws_sdk_s3::config::{retry::RetryConfig, Credentials, Region};
let config = aws_sdk_s3::config::Config::builder()
.endpoint_url(endpoint)
.credentials_provider(Credentials::new("test", "test", None, None, "test"))
.region(Region::new("auto"))
.retry_config(RetryConfig::disabled())
.behavior_version_latest()
.build();
Self {
s3: S3Client::new(&config),
s3: S3Client::from_conf(config),
bucket: bucket.to_string(),
compress_gate: None,
}
}
/// Test-only: park this client's blocking compression on `gate` until the
/// holder releases it, so a cancellation can be aimed at
/// [`PublishStage::PreparingArchive`] rather than at the PUT.
#[cfg(test)]
pub fn with_compress_gate(mut self, gate: Arc<BlockingGate>) -> Self {
self.compress_gate = Some(gate);
self
}
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
@@ -77,34 +201,325 @@ impl TigrisClient {
}
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
/// Read the ETag of a repo archive, or `None` when nothing is stored under
/// the key. The ETag identifies the generation a later conditional upload
/// can fence itself on.
///
/// Separate from `exists` rather than folded into it: `exists` has callers
/// that only want the boolean, and widening its return type would churn
/// every one of them for no benefit.
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
Ok(self
.head_generation(owner_slug, repo_name)
.await?
.map(|g| g.etag)
.unwrap_or(None))
}
// Create tar.zst in memory
let archive_bytes = tokio::task::spawn_blocking({
let local_path = local_path.to_path_buf();
move || compress_repo(&local_path)
})
/// The full stored generation: the ETag AND the attempt that published it.
///
/// The attempt half is what makes a lost response recoverable. An ETag alone
/// answers "has the generation moved", which every writer sees the same way;
/// the attempt id answers "are the published bytes MINE", which is the
/// question a client whose PUT response vanished actually needs answered.
pub async fn head_generation(
&self,
owner_slug: &str,
repo_name: &str,
) -> Result<Option<StoredGeneration>> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
{
Ok(out) => {
let etag = out
.e_tag()
.context(format!("tigris HEAD {key}: hit carried no ETag"))?
.to_string();
let attempt = out
.metadata()
.and_then(|m| m.get(ATTEMPT_METADATA_KEY))
.cloned();
Ok(Some(StoredGeneration {
etag: Some(etag),
attempt,
}))
}
Err(e) => {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
Ok(None)
} else {
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
}
}
}
}
/// Did `attempt`'s bytes land? The reconciliation an ambiguous dispatch owes
/// before anything downstream may treat it as confirmation.
///
/// `Ok(false)` is deliberately NOT "the PUT failed": an abandoned request may
/// still be in flight, so a negative answer licenses refusing and retrying,
/// never deleting. Only `Ok(true)` upgrades an unresolved attempt to
/// published.
pub async fn attempt_landed(
&self,
owner_slug: &str,
repo_name: &str,
attempt: &PublishAttemptId,
) -> Result<bool> {
Ok(self
.head_generation(owner_slug, repo_name)
.await?
.is_some_and(|g| g.belongs_to(attempt)))
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive,
/// fenced by `precondition`, under a freshly minted attempt identity.
pub async fn upload(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
precondition: UploadPrecondition,
) -> std::result::Result<UploadReceipt, UploadError> {
self.upload_tracked(
owner_slug,
repo_name,
local_path,
precondition,
PublishAttemptId::new(),
None,
)
.await
.context("tar task panicked")?
.context("compressing repo")?;
}
/// The full form: a caller-chosen attempt identity, and a stage cell the
/// upload reports its progress into.
///
/// The stage cell is the answer to "a dropped future never returns an
/// outcome". Compression runs inside `spawn_blocking` and the conditional PUT
/// is not even constructed until it finishes, so a handler cancelled during
/// compression definitely never attempted publication — but nothing could
/// observe that, because the only report was the return value of a future
/// that no longer exists. Marking [`PublishStage::PreparingArchive`] before
/// the blocking call and [`PublishStage::PutDispatched`] immediately before
/// `send()` makes that boundary readable from outside.
pub async fn upload_tracked(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
precondition: UploadPrecondition,
attempt: PublishAttemptId,
stage: Option<&PublishStageCell>,
) -> std::result::Result<UploadReceipt, UploadError> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), attempt = %attempt, "uploading repo to tigris");
if let Some(stage) = stage {
stage.set(PublishStage::PreparingArchive);
}
// Create tar.zst in memory. Nothing has been sent at this point and
// nothing can be: the request below is not constructed until this
// returns. A failure or a cancellation here is a definite
// non-publication.
let compressed = {
let local_path = local_path.to_path_buf();
#[cfg(test)]
let gate = self.compress_gate.clone();
tokio::task::spawn_blocking(move || {
// Test-only seam: park INSIDE the blocking compression, which is
// the window a handler can be cancelled in while
// `PublishStage::PreparingArchive` still holds.
#[cfg(test)]
if let Some(gate) = gate {
// Blocks until the test opens the gate.
gate.wait();
}
compress_repo(&local_path)
})
.await
};
let archive_bytes = match compressed {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
return Err(UploadError::NotPublished(e.context("compressing repo")));
}
Err(e) => {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
return Err(UploadError::NotPublished(
anyhow::Error::new(e).context("tar task panicked"),
));
}
};
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
self.s3
let mut req = self
.s3
.put_object()
.bucket(&self.bucket)
.key(&key)
// The attempt identity travels WITH the bytes. That is what makes a
// lost response recoverable: the client can HEAD the key afterwards
// and ask whether the published object is its own work, which is
// decidable, instead of asking whether its request succeeded, which
// is not.
.metadata(ATTEMPT_METADATA_KEY, attempt.as_str())
.body(body)
.content_type("application/zstd")
.send()
.await
.context(format!("tigris PUT {key}"))?;
.content_type("application/zstd");
match &precondition {
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
UploadPrecondition::Unconditional => {}
}
info!(key = %key, "uploaded repo to tigris");
Ok(())
// From HERE the bytes may reach the store. Everything downstream that
// could destroy state has to treat this stage as "maybe published".
if let Some(stage) = stage {
stage.set(PublishStage::PutDispatched {
attempt: attempt.clone(),
});
}
let sent = req.send().await;
let out = match sent {
Ok(out) => out,
Err(e) => {
// `PutObjectError` models no PreconditionFailed variant (its arms are
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
// TooManyParts, Unhandled), so a refused precondition arrives as
// `Unhandled` and matching the enum would classify it as a generic
// failure. The raw HTTP status off the response is the only place the
// answer actually lives.
//
// Read it via `raw_response()`, not a `ServiceError`-only match: the
// SDK exposes the raw response for BOTH `ServiceError` and
// `ResponseError`, and a refused conditional PUT whose error body the
// SDK cannot parse (malformed XML, premature close) surfaces as
// `ResponseError`. Matching only `ServiceError` would classify that
// unparsable 409/412 as a generic failure, and `RepoWriteGuard::release`
// would log-and-succeed instead of taking the supersede retry,
// acknowledging a write that was definitively not published.
let status = e.raw_response().map(|raw| raw.status().as_u16());
// 412 is always a lost precondition. 409 is one only when we asked
// for create-only, which is how S3-compatible stores report "the key
// already exists". Everything else, 404 included, is a real failure:
// archive keys are never deleted by the write path, so a 404 here
// means something permanent like a missing bucket or a misrouted
// endpoint, and reporting that as a lost precondition would tell a
// caller to expect a successor that does not exist.
let lost = match status {
Some(412) => true,
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
_ => false,
};
if lost {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
return Err(UploadError::PreconditionLost {
status: status.expect("a lost precondition came from a status"),
});
}
let knowledge = classify_put_failure(&e, status);
let ctx = anyhow::Error::new(e).context(format!("tigris PUT {key}"));
return Err(match knowledge {
DispatchKnowledge::NeverSent => {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
UploadError::NotPublished(ctx)
}
DispatchKnowledge::MaybeSent => {
warn!(
key = %key,
attempt = %attempt,
status = ?status,
"tigris PUT failed WITHOUT proving it did not commit — the outcome \
is ambiguous and must be reconciled, not compensated"
);
if let Some(stage) = stage {
stage.set(PublishStage::Ambiguous {
attempt: attempt.clone(),
});
}
UploadError::Ambiguous {
attempt: attempt.clone(),
source: ctx,
}
}
});
}
};
let etag = out.e_tag().map(str::to_string);
if let Some(stage) = stage {
stage.set(PublishStage::Published {
attempt: attempt.clone(),
etag: etag.clone(),
});
}
info!(key = %key, attempt = %attempt, "uploaded repo to tigris");
Ok(UploadReceipt { attempt, etag })
}
/// Delete a repo archive ONLY while it is still `attempt`'s work.
///
/// The unconditional delete this replaces used the logical owner/name as its
/// cleanup authority, so a failed attempt compensating late could erase the
/// object a SUCCESSOR had already published under the same name and already
/// returned 201 for. A second name lookup before the delete only narrows that
/// window; reading the attempt id off the object and fencing the delete on
/// the generation it came from closes it.
pub async fn delete_if_attempt_matches(
&self,
owner_slug: &str,
repo_name: &str,
attempt: &PublishAttemptId,
) -> Result<AttemptDelete> {
let Some(generation) = self.head_generation(owner_slug, repo_name).await? else {
return Ok(AttemptDelete::Absent);
};
if !generation.belongs_to(attempt) {
return Ok(AttemptDelete::NotOurs);
}
let key = Self::repo_key(owner_slug, repo_name);
let mut req = self.s3.delete_object().bucket(&self.bucket).key(&key);
// The If-Match guard is what makes this atomic rather than merely
// narrowed: between the HEAD above and this call a successor can publish,
// and the store refusing on the moved generation is the only thing that
// stops the delete landing on their object.
if let Some(etag) = generation.etag.as_deref() {
req = req.if_match(etag);
}
match req.send().await {
Ok(_) => Ok(AttemptDelete::Deleted),
Err(e) => {
// A refused conditional delete means the generation moved: the
// object is no longer ours, which is a successful outcome for a
// guard whose whole job is not to touch somebody else's bytes.
if e.raw_response()
.map(|raw| raw.status().as_u16())
.is_some_and(|s| s == 412 || s == 409)
{
return Ok(AttemptDelete::NotOurs);
}
Err(anyhow::anyhow!("tigris conditional DELETE {key}: {e}"))
}
}
}
/// Download a repo archive from Tigris and extract to local disk.
@@ -112,10 +527,32 @@ impl TigrisClient {
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
local_path: &super::repo_store::ValidatedRepoDiskPath,
swap_authority: Option<Arc<AtomicBool>>,
) -> Result<()> {
self.download_to(owner_slug, repo_name, local_path, true, swap_authority)
.await
.map(|_| ())
}
/// Download a repo archive from Tigris and extract it, returning the
/// directory that was populated.
///
/// `publish` controls whether the extract is swapped into `target` in place
/// (the live-path mutation used by writes; returns `target`) or unpacked
/// into a fresh temp directory under `target`'s parent (a non-mutating
/// snapshot read; returns the temp dir, which the caller owns and cleans
/// up). The snapshot form never touches the live repo path.
pub async fn download_to(
&self,
owner_slug: &str,
repo_name: &str,
target: &super::repo_store::ValidatedRepoDiskPath,
publish: bool,
swap_authority: Option<Arc<AtomicBool>>,
) -> Result<DownloadExtract> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
let resp = self
.s3
@@ -133,31 +570,59 @@ impl TigrisClient {
.context("reading tigris response body")?
.into_bytes();
// Extract tar.zst to local path
tokio::task::spawn_blocking({
let local_path = local_path.to_path_buf();
move || decompress_repo(&data, &local_path)
// The snapshot temp dir is decided HERE, in the async layer, before the
// extraction runs. Cleanup ownership moves into the blocking task so a
// cancelled async future cannot drop it while extraction is still running.
let snapshot_tmp = if publish {
None
} else {
let parent = target.parent().context("snapshot path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = target
.file_name()
.context("snapshot path has no file name")?
.to_string_lossy();
Some(parent.join(format!(
".{file_name}.tmp-snapshot.{}",
uuid::Uuid::new_v4()
)))
};
// Extract tar.zst to a directory.
let extracted = tokio::task::spawn_blocking({
let target = target.clone();
let snapshot_tmp = snapshot_tmp.clone();
move || -> Result<DownloadExtract> {
let result = (|| -> Result<DownloadExtract> {
if publish {
decompress_repo(&data, &target, swap_authority.as_ref())?;
return Ok(DownloadExtract::Published(()));
}
// Non-mutating snapshot: unpack into the temp dir decided above.
// The live repo path is never touched.
let tmp_dir = snapshot_tmp.expect("snapshot path was decided above");
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
let unpack = (|| -> Result<()> {
let decoder = zstd::stream::Decoder::new(&data[..])?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
Ok(())
})();
if let Err(e) = unpack {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(e);
}
Ok(DownloadExtract::Snapshot(TempSnapshotDir { path: tmp_dir }))
})();
result
}
})
.await
.context("extract task panicked")?
.context("extracting repo")?;
info!(key = %key, path = %local_path.display(), "downloaded repo from tigris");
Ok(())
}
/// Delete a repo archive from Tigris.
#[allow(dead_code)]
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
self.s3
.delete_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.context(format!("tigris DELETE {key}"))?;
Ok(())
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
Ok(extracted)
}
}
@@ -180,7 +645,7 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
// entry accrues per distinct repo path for the process lifetime. Bounded by
// the number of repos a node hosts, so it's negligible for normal use, but
@@ -193,6 +658,35 @@ fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
.clone()
}
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
/// before the outer future resumes still removes the directory.
pub(crate) struct TempSnapshotDir {
path: PathBuf,
}
impl TempSnapshotDir {
pub(crate) fn into_repo_snapshot(self) -> super::repo_store::RepoSnapshot {
let path = self.path.clone();
std::mem::forget(self);
super::repo_store::RepoSnapshot::from_owned_path(path)
}
}
impl Drop for TempSnapshotDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// What [`TigrisClient::download_to`] produced.
pub enum DownloadExtract {
/// Published into the validated live repo path.
Published(()),
/// Unpacked into a throwaway temp dir that cleans up on drop until adopted.
Snapshot(TempSnapshotDir),
}
/// Decompress a tar.zst byte vector into a local directory.
///
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
@@ -200,11 +694,16 @@ fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
/// good existing copy at `local_path` — on failure we discard the temp dir and
/// leave `local_path` exactly as it was.
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
let parent = local_path.parent().context("repo path has no parent")?;
fn decompress_repo(
data: &[u8],
local_path: &super::repo_store::ValidatedRepoDiskPath,
swap_authority: Option<&Arc<AtomicBool>>,
) -> Result<()> {
let live = local_path.as_path();
let parent = live.parent().context("repo path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = local_path
let file_name = live
.file_name()
.context("repo path has no file name")?
.to_string_lossy();
@@ -229,17 +728,218 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
return Err(e);
}
// Swap the freshly-extracted repo into place. rename within the same parent
// is effectively atomic, but most platforms refuse to rename onto a
// non-empty dir, so remove the old copy first. Serialize this per repo path:
// concurrent extractions unpack into isolated temp dirs, but their swaps
// must not interleave or they race to a nondeterministic overwrite/failure.
let lock = publish_lock(local_path);
let _publish = lock.lock().expect("publish lock poisoned");
if local_path.exists() {
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
}
std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place")?;
// Swap through the validated-path helper so CodeQL sees the barrier before the
// remove/rename sink (`rust/path-injection`).
super::repo_store::swap_extracted_into_validated_repo(local_path, &tmp_dir, swap_authority)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aws_sdk_s3::primitives::ByteStream;
use futures::FutureExt;
/// The envs the probe needs, all of them, or it does not run.
///
/// `AWS_ENDPOINT_URL_S3` is included on purpose: without it the SDK resolves
/// to real AWS S3, and a probe that passed there would say nothing about
/// Tigris.
fn probe_env() -> Option<String> {
if std::env::var("GITLAWB_TIGRIS_PROBE").ok().as_deref() != Some("1") {
return None;
}
for name in [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_ENDPOINT_URL_S3",
] {
if std::env::var(name).is_err() {
eprintln!("tigris conditional-write probe: {name} is unset, skipping");
return None;
}
}
match std::env::var("GITLAWB_TIGRIS_BUCKET") {
Ok(b) if !b.is_empty() => Some(b),
_ => {
eprintln!(
"tigris conditional-write probe: GITLAWB_TIGRIS_BUCKET is unset, skipping"
);
None
}
}
}
/// One conditional PUT, reported as the status that REFUSED it, or `None`
/// when the store accepted the write.
///
/// Accepted is the interesting answer here, not an error: it means the
/// endpoint ignored the header we fenced on.
async fn conditional_put(
s3: &S3Client,
bucket: &str,
key: &str,
body: &'static [u8],
if_match: Option<&str>,
if_none_match: Option<&str>,
) -> Result<Option<u16>, String> {
let mut req = s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body));
if let Some(v) = if_match {
req = req.if_match(v);
}
if let Some(v) = if_none_match {
req = req.if_none_match(v);
}
match req.send().await {
Ok(_) => Ok(None),
Err(e) => match e.raw_response() {
// Same raw-response rule as `upload`: a refused conditional PUT
// whose body the SDK cannot parse surfaces as `ResponseError`, and
// the status has to come off the raw response for both variants.
// Without it, an unparsable 409/412 would report "no HTTP
// response" here and skip the supersede retry.
Some(raw) => Ok(Some(raw.status().as_u16())),
None => Err(format!("conditional PUT {key}: no HTTP response: {e}")),
},
}
}
/// The probe body, written to RETURN its failures rather than panic on
/// them, so the caller's cleanup is reached on every arm.
async fn conditional_write_probe(s3: &S3Client, bucket: &str, key: &str) -> Result<(), String> {
// 1. A plain PUT under a throwaway key.
let seeded = s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"probe-one"))
.send()
.await
.map_err(|e| format!("seeding PUT {key}: {e}"))?;
// 2. Its ETag, which is the generation the next arm fences against.
let etag = seeded
.e_tag()
.ok_or_else(|| format!("seeding PUT {key} returned no ETag"))?
.to_string();
// 3. A deliberately wrong If-Match. A store honoring it answers 412.
let wrong = format!("\"{}\"", "0".repeat(32));
if etag.trim_matches('"') == wrong.trim_matches('"') {
return Err(format!(
"the seeded ETag {etag} collides with the deliberately wrong one, \
so this arm would prove nothing"
));
}
match conditional_put(s3, bucket, key, b"probe-two", Some(&wrong), None).await? {
Some(412) => {}
Some(status) => {
return Err(format!(
"a stale If-Match must be refused with 412, the endpoint answered {status}"
))
}
None => {
return Err(
"a stale If-Match was ACCEPTED: this endpoint does not honor If-Match, so \
the release fence cannot hold here"
.to_string(),
)
}
}
// 4. If-None-Match `*` over the object that now exists. This arm matters
// MORE than the one above. An ignored If-Match eventually surfaces as
// odd behavior, because a stale writer overwrites and someone notices
// the lost tree. An ignored If-None-Match just returns 200, so a publish
// that should have been fenced lands with no error anywhere: the silent
// no-op the bucket-type caveat on this test describes.
match conditional_put(s3, bucket, key, b"probe-three", None, Some("*")).await? {
// Either status is a pass, and the asymmetry with the If-Match arm
// above mirrors `upload`'s classifier exactly: 412 is always a lost
// precondition, and 409 is one too when we asked for create-only.
// AWS documents 409 for a create-only conflict racing a delete, so a
// store answering it is enforcing the precondition and we already
// handle it. Pinning 412 alone here would fail the probe against a
// backend that is behaving correctly, which sends whoever runs it
// chasing a fault that is not there.
Some(412) | Some(409) => {}
Some(status) => {
return Err(format!(
"create-only over an existing object must be refused with 412 or 409, \
the endpoint answered {status}"
))
}
None => {
return Err(
"If-None-Match * was ACCEPTED over an existing object: this endpoint does \
not honor create-only, so a fenced publish lands silently"
.to_string(),
)
}
}
Ok(())
}
/// Probe the REAL Tigris endpoint for the conditional-write semantics the
/// release fence depends on.
///
/// UNTIL THIS IS RUN AGAINST REAL CREDENTIALS, the fence is verified against
/// vendor documentation and an in-process mock, not against the backend it
/// runs on. The mock implements the semantics we believe Tigris has; it
/// cannot tell us whether Tigris actually has them.
///
/// The bucket matters, not just the endpoint. Tigris documents conditional
/// operations as supported on Single-region and Multi-region buckets only.
/// Global and Dual-region buckets are eventually consistent, and a
/// conditional PUT evaluated against a stale replica would make the fence a
/// silent no-op rather than an error. So point `GITLAWB_TIGRIS_BUCKET` at a
/// throwaway bucket of the SAME type production uses.
///
/// Ignored by default and additionally gated on `GITLAWB_TIGRIS_PROBE=1`,
/// because it writes to a real bucket and costs real requests. Run with:
/// `GITLAWB_TIGRIS_PROBE=1 cargo test -p gitlawb-node --bin gitlawb-node
/// tigris_honors_conditional_writes -- --ignored --nocapture`
#[tokio::test]
#[ignore = "writes to a real Tigris bucket; needs GITLAWB_TIGRIS_PROBE=1 plus credentials"]
async fn tigris_honors_conditional_writes() {
let Some(bucket) = probe_env() else {
eprintln!(
"tigris conditional-write probe: skipped. Set GITLAWB_TIGRIS_PROBE=1, \
GITLAWB_TIGRIS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and \
AWS_ENDPOINT_URL_S3 to run it."
);
return;
};
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let s3 = S3Client::new(&config);
// A fresh key per run, so a probe that somehow orphaned an object on an
// earlier run cannot change what this one observes.
let key = format!("probe/conditional-write-{}.bin", uuid::Uuid::new_v4());
// CLEANUP MUST RUN ON EVERY ARM, and a failing assertion is precisely
// the case this probe exists to catch, so the delete cannot sit after
// the checks. The body returns its failures rather than panicking, and
// `catch_unwind` covers the panic an SDK call could still raise; either
// way the delete below is reached before the verdict is re-raised.
let outcome = std::panic::AssertUnwindSafe(conditional_write_probe(&s3, &bucket, &key))
.catch_unwind()
.await;
if let Err(e) = s3.delete_object().bucket(&bucket).key(&key).send().await {
eprintln!("tigris conditional-write probe: cleanup of {key} failed: {e}");
}
match outcome {
Ok(Ok(())) => {}
Ok(Err(msg)) => panic!("tigris conditional-write probe: {msg}"),
Err(payload) => std::panic::resume_unwind(payload),
}
}
}
+359 -29
View File
@@ -1,7 +1,9 @@
//! Resolve which blob OIDs must be withheld from a caller because every path
//! at which the blob appears is denied by the repo's visibility rules. Trees
//! and commits are never withheld (mode B keeps SHAs intact); only blob
//! content is held back.
//! Resolve which object OIDs must be withheld from a caller because every path
//! at which the object appears is denied by the repo's visibility rules. Blobs
//! and subtree trees are withholdable (mode B; trees since #135/#173). Commits
//! and tags are never withheld: they are root-level metadata a "/" reader already
//! clears. The replication pin path uses [`withheld_object_oids_bounded`] so a
//! withheld-subtree tree is not exported to public IPFS (#172).
use crate::db::VisibilityRule;
use crate::visibility::{visibility_check, Decision};
@@ -540,6 +542,79 @@ pub fn withheld_blob_oids_bounded(
))
}
/// Reachable blob AND withheld-subtree tree OIDs the anonymous (or `caller`)
/// replication audience must not receive. The single source for
/// `replication_withheld_set`: a blob-only set handed to [`replicable_objects`]
/// reintroduces #172 (withheld trees pin to public IPFS).
///
/// Strict where [`tree_paths`] is lenient. This feeds a withheld filter, so
/// absence means replicate; a missed reachable tree would leak. The walk
/// therefore matches [`blob_paths`]: [`assert_all_refs_are_commits`] first, then
/// a failing `rev-list --all [HEAD]` is an error, then [`object_paths`] (already
/// fail-closed on ls-tree / non-UTF-8) plus [`root_tree_pairs`]. [`tree_paths`]
/// stays the lenient allow-list walk for GET /ipfs/{cid} and the full-scan
/// allowed-tree set, where absence withholds.
pub fn withheld_object_oids_bounded(
repo_path: &Path,
git_bin: &str,
timeout: Duration,
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Result<HashSet<String>> {
let deadline = Instant::now() + timeout;
assert_all_refs_are_commits(repo_path, git_bin, deadline)?;
let head_resolves = run_bounded_git(
git_bin,
&["rev-parse", "--verify", "HEAD"],
repo_path,
b"",
deadline,
)
.is_ok();
let mut rev_args = vec!["rev-list", "--all"];
if head_resolves {
rev_args.push("HEAD");
}
let commits_out = run_bounded_git(git_bin, &rev_args, repo_path, b"", deadline)?;
let commits: Vec<String> = String::from_utf8_lossy(&commits_out)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
let triples = object_paths(repo_path, git_bin, &commits, deadline)?;
let mut pairs: Vec<(String, String)> = triples
.into_iter()
.filter(|(_, _, kind)| kind == "blob" || kind == "tree")
.map(|(oid, path, _)| (oid, path))
.collect();
pairs.extend(root_tree_pairs(repo_path, git_bin, &commits, deadline)?);
Ok(withheld_from_pairs(
&pairs, rules, is_public, owner_did, caller,
))
}
/// [`withheld_object_oids_bounded`] with the test walk budget.
#[cfg(test)]
pub fn withheld_object_oids(
repo_path: &Path,
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Result<HashSet<String>> {
withheld_object_oids_bounded(
repo_path,
"git",
WALK_TIMEOUT,
rules,
is_public,
owner_did,
caller,
)
}
/// Withheld set from an already-computed (oid, "/path") listing: a blob is
/// withheld only when visibility denies the caller at *every* path it appears
/// at. Split out so a caller that already walked `blob_paths` (e.g.
@@ -588,8 +663,9 @@ pub fn has_path_scoped_rule(rules: &[VisibilityRule]) -> bool {
/// Objects that may replicate to the public: everything not in `withheld`.
/// Order-preserving. The single seam every replication site (IPFS, Pinata)
/// passes its object list through; option B would later reroute the withheld
/// ones through encrypt-then-pin instead of dropping them.
/// passes its object list through on the delta path. `withheld` MUST be the
/// combined blob-and-tree set from [`withheld_object_oids_bounded`] (via
/// `replication_withheld_set`); a blob-only set reintroduces #172.
pub fn replicable_objects(all: Vec<String>, withheld: &HashSet<String>) -> Vec<String> {
all.into_iter()
.filter(|oid| !withheld.contains(oid))
@@ -627,6 +703,23 @@ pub fn replicable_blob_set_bounded(
)
}
/// Reachable tree OIDs that visibility ALLOWS the anonymous replication
/// audience at some path. The tree analog of [`replicable_blob_set_bounded`],
/// used on the fail-closed full-scan pin path so a withheld or dangling tree
/// is absent and dropped (#172).
pub fn replicable_tree_set_bounded(
repo_path: &Path,
git_bin: &str,
timeout: Duration,
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
) -> Result<HashSet<String>> {
allowed_tree_set_for_caller_bounded(
repo_path, git_bin, timeout, rules, is_public, owner_did, None,
)
}
/// Reachable blob OIDs that visibility ALLOWS `caller` at some path. The
/// caller-aware generalization of `replicable_blob_set` (which is the anonymous
/// `caller = None` case). Used by `GET /ipfs/{cid}` to gate fail-closed against
@@ -827,11 +920,12 @@ fn root_tree_pairs(
/// Every `(tree_oid, "/path")` pair reachable in `repo_path`: the `kind == "tree"`
/// slice of [`object_paths`] (subtree trees at their directory paths) PLUS every
/// reachable commit's root tree at "/" (see [`root_tree_pairs`]). Computes the
/// reachable-commit set ONCE (leniently — see [`reachable_commit_oids`]; the tree
/// allowed-set feeds ONLY the `/ipfs/{cid}` tree gate, where absence = fail-closed
/// 404) and drives both the ls-tree walk and the root-tree pass from it, so the two
/// cannot diverge and neither re-enumerates. The tree analog of [`blob_paths`],
/// bounded by the same shared `deadline`.
/// reachable-commit set ONCE (leniently, see [`reachable_commit_oids`]; the tree
/// allowed-set feeds the `/ipfs/{cid}` tree gate AND the full-scan pin allow-list,
/// where absence = fail-closed) and drives both the ls-tree walk and the root-tree
/// pass from it, so the two cannot diverge and neither re-enumerates. The tree analog
/// of [`blob_paths`], bounded by the same shared `deadline`. Not for a withheld
/// filter: that path is [`withheld_object_oids_bounded`].
fn tree_paths(
repo_path: &Path,
git_bin: &str,
@@ -1150,22 +1244,29 @@ pub fn reachable_commit_tag_oids_bounded(
Ok(set)
}
/// Objects safe to replicate, failing closed on blobs (#99). A candidate
/// replicates iff it is NOT a blob (`all_blob_oids` — commits and trees are
/// structural, never content-withheld) OR it is in `allowed_blobs` (reachable
/// and visibility-allowed). This drops both withheld reachable blobs and
/// dangling/unreachable blobs the reachable walk never classified, without
/// tagging the candidate list with per-object types. Used on the full-scan pin
/// path, where the candidate set can contain dangling objects the reachable-only
/// withheld set cannot cover; the delta path keeps `replicable_objects`.
/// Objects safe to replicate, failing closed on blobs AND trees (#99, #172).
/// A candidate replicates iff it is neither a known blob nor a known tree
/// (commits and tags: structural, a "/" reader already clears) OR it is in
/// `allowed_blobs` OR it is in `allowed_trees`. Trees have been content-withholdable
/// since #135/#173; the old premise that they always pass is falsified. A dangling
/// or withheld tree is in `all_tree_oids` and absent from `allowed_trees`, so it
/// drops. Used on the full-scan pin path, where the candidate set can contain
/// dangling objects the reachable-only withheld set cannot cover; the delta path
/// keeps [`replicable_objects`].
pub fn replicable_objects_fail_closed(
candidates: Vec<String>,
allowed_blobs: &HashSet<String>,
all_blob_oids: &HashSet<String>,
allowed_trees: &HashSet<String>,
all_tree_oids: &HashSet<String>,
) -> Vec<String> {
candidates
.into_iter()
.filter(|oid| !all_blob_oids.contains(oid) || allowed_blobs.contains(oid))
.filter(|oid| {
let is_blob = all_blob_oids.contains(oid);
let is_tree = all_tree_oids.contains(oid);
(!is_blob && !is_tree) || allowed_blobs.contains(oid) || allowed_trees.contains(oid)
})
.collect()
}
@@ -2384,28 +2485,49 @@ esac\n";
}
#[test]
fn fail_closed_keeps_nonblobs_and_allowed_blobs_only() {
// Non-blob objects (commit/tree) always pass; a blob passes only if it
// is in the allowed set. A withheld blob and a dangling blob (both in
// all_blob_oids, neither in allowed) are dropped.
let allowed: HashSet<String> = ["b_pub".to_string()].into_iter().collect();
fn fail_closed_keeps_commits_allowed_blobs_and_allowed_trees_only() {
// Deliberate invariant flip of fail_closed_keeps_nonblobs_and_allowed_blobs_only
// (#172). That test's premise ("commits and trees are structural, never
// content-withheld") was falsified by #135/#173: a withheld-subtree tree is
// content, same as a withheld blob. Commits and tags still pass; a blob or
// tree passes only if it is in its allowed set. Withheld and dangling blobs
// and trees (in the all-set, absent from allowed) drop.
let allowed_blobs: HashSet<String> = ["b_pub".to_string()].into_iter().collect();
let all_blobs: HashSet<String> = ["b_pub", "b_secret", "b_dangling"]
.into_iter()
.map(String::from)
.collect();
let allowed_trees: HashSet<String> =
["t_root", "t_pub"].into_iter().map(String::from).collect();
let all_trees: HashSet<String> = ["t_root", "t_pub", "t_secret", "t_dangling"]
.into_iter()
.map(String::from)
.collect();
let candidates = vec![
"commit1".to_string(),
"tree1".to_string(),
"tag1".to_string(),
"t_root".to_string(),
"t_pub".to_string(),
"t_secret".to_string(),
"t_dangling".to_string(),
"b_pub".to_string(),
"b_secret".to_string(),
"b_dangling".to_string(),
];
let got = replicable_objects_fail_closed(candidates, &allowed, &all_blobs);
let got = replicable_objects_fail_closed(
candidates,
&allowed_blobs,
&all_blobs,
&allowed_trees,
&all_trees,
);
assert_eq!(
got,
vec![
"commit1".to_string(),
"tree1".to_string(),
"tag1".to_string(),
"t_root".to_string(),
"t_pub".to_string(),
"b_pub".to_string()
]
);
@@ -2482,8 +2604,12 @@ esac\n";
);
// Full-scan candidate set includes the dangling blob; fail-closed drops it.
// Empty tree sets keep today's keep-all-trees behavior for this blob-only
// case (U1 signature adapt; U3 wires the real tree walks).
let no_trees: HashSet<String> = HashSet::new();
let candidates = vec![dangling_oid.clone(), public_oid.clone()];
let replicable = replicable_objects_fail_closed(candidates, &allowed, &all_blobs);
let replicable =
replicable_objects_fail_closed(candidates, &allowed, &all_blobs, &no_trees, &no_trees);
assert!(
!replicable.contains(&dangling_oid),
"#99: a dangling private blob must not replicate"
@@ -2494,6 +2620,210 @@ esac\n";
);
}
#[test]
fn withheld_object_set_contains_withheld_subtree_tree() {
// #172 repro as a test: the withheld /secret tree must enter the combined
// withheld set (today's blob-only walk omits it because trees are never in
// all_blob_oids). Nested secret/nested is the same leak class.
let td = TempDir::new().unwrap();
let work = td.path().join("work");
let bare = td.path().join("bare.git");
let run = |args: &[&str], dir: &Path| {
assert!(
Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
std::fs::create_dir_all(work.join("public")).unwrap();
std::fs::create_dir_all(work.join("secret/nested")).unwrap();
std::fs::write(work.join("public/a.txt"), b"public bytes\n").unwrap();
std::fs::write(work.join("secret/b.txt"), b"TOP SECRET\n").unwrap();
std::fs::write(work.join("secret/nested/c.txt"), b"NESTED SECRET\n").unwrap();
run(&["init", "-q"], &work);
run(&["config", "user.email", "t@t"], &work);
run(&["config", "user.name", "t"], &work);
run(&["add", "."], &work);
run(&["commit", "-qm", "init"], &work);
run(
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
let oid = |rev: &str| {
let out = Command::new("git")
.args(["rev-parse", rev])
.current_dir(&bare)
.output()
.unwrap();
assert!(out.status.success(), "rev-parse {rev}");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let secret_tree = oid("HEAD:secret");
let nested_tree = oid("HEAD:secret/nested");
let secret_blob = oid("HEAD:secret/b.txt");
let public_tree = oid("HEAD:public");
let public_blob = oid("HEAD:public/a.txt");
let root_tree = oid("HEAD^{tree}");
let head = oid("HEAD");
let rules = [rule("/secret/**", &[])];
let withheld = withheld_object_oids(&bare, &rules, true, OWNER, None).unwrap();
assert!(
withheld.contains(&secret_tree),
"withheld /secret tree must be in the combined withheld set (#172)"
);
assert!(
withheld.contains(&nested_tree),
"nested withheld tree secret/nested must also be withheld"
);
assert!(
withheld.contains(&secret_blob),
"withheld blob remains withheld"
);
assert!(!withheld.contains(&root_tree), "root tree is allowed at /");
assert!(
!withheld.contains(&public_tree),
"public subtree tree is not withheld"
);
assert!(
!withheld.contains(&public_blob),
"public blob is not withheld"
);
assert!(!withheld.contains(&head), "commits are never withheld");
let candidates_out = Command::new("git")
.args(["rev-list", "--objects", "--no-object-names", "HEAD"])
.current_dir(&bare)
.output()
.unwrap();
assert!(candidates_out.status.success());
let candidates: Vec<String> = String::from_utf8_lossy(&candidates_out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
let replicable = replicable_objects(candidates, &withheld);
assert!(
replicable.contains(&head) && replicable.contains(&root_tree),
"commit and root tree still replicate"
);
assert!(
replicable.contains(&public_tree) && replicable.contains(&public_blob),
"public tree and blob still replicate"
);
assert!(
!replicable.contains(&secret_tree) && !replicable.contains(&secret_blob),
"secret tree and blob must drop from the replicable set (#172)"
);
assert!(
!replicable.contains(&nested_tree),
"nested withheld tree must drop too"
);
}
#[test]
fn withheld_object_set_blob_slice_matches_withheld_blob_oids() {
let (_td, bare, _s, _p) = fixture();
let rules = [rule("/secret/**", &[])];
let combined = withheld_object_oids(&bare, &rules, true, OWNER, None).unwrap();
let blobs = withheld_blob_oids(&bare, &rules, true, OWNER, None).unwrap();
let all_blobs =
crate::git::push_delta::all_blob_oids(&bare, "git", Instant::now() + WALK_TIMEOUT)
.unwrap();
let blob_slice: HashSet<String> = combined.intersection(&all_blobs).cloned().collect();
assert_eq!(
blob_slice, blobs,
"combined withheld set's blob slice must equal withheld_blob_oids (R4)"
);
}
#[test]
fn withheld_object_set_keeps_tree_shared_across_allowed_and_denied_paths() {
let td = TempDir::new().unwrap();
let work = td.path().join("work");
std::fs::create_dir_all(work.join("pub/sub")).unwrap();
std::fs::create_dir_all(work.join("sec/sub")).unwrap();
std::fs::write(work.join("pub/sub/f.txt"), b"same bytes\n").unwrap();
std::fs::write(work.join("sec/sub/f.txt"), b"same bytes\n").unwrap();
let run = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&work)
.status()
.unwrap()
.success(),
"git {args:?}"
);
};
run(&["init", "-q"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
run(&["add", "."]);
run(&["commit", "-qm", "seed"]);
let oid = |rev: &str| {
let out = Command::new("git")
.args(["rev-parse", rev])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let pub_sub = oid("HEAD:pub/sub");
let sec_sub = oid("HEAD:sec/sub");
assert_eq!(pub_sub, sec_sub, "identical content dedups to one tree oid");
let rules = [rule("/sec/**", &[])];
let withheld = withheld_object_oids(&work, &rules, true, OWNER, None).unwrap();
assert!(
!withheld.contains(&pub_sub),
"a tree reachable at an allowed path is not withheld even when also at a denied path"
);
}
#[test]
fn withheld_object_set_empty_with_root_only_rules() {
let (_td, bare, _s, _p) = fixture();
// A caller who has already passed the "/" gate: the listed reader of a
// root-only rule. Path-scoped withholding cannot apply, so the set is empty
// (R7, the has_path_scoped_rule short-circuit premise).
let rules = [rule("/", &["did:key:zFriend"])];
let withheld =
withheld_object_oids(&bare, &rules, true, OWNER, Some("did:key:zFriend")).unwrap();
assert!(
withheld.is_empty(),
"root-only rules cannot withhold a blob or tree from a caller who passed the / gate (R7)"
);
}
#[test]
fn withheld_object_walk_fails_closed_on_non_commit_ref() {
let (_td, bare, _s, _p) = fixture();
let secret_tree = {
let out = Command::new("git")
.args(["rev-parse", "HEAD:secret"])
.current_dir(&bare)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::fs::write(bare.join("refs/heads/treeref"), format!("{secret_tree}\n")).unwrap();
let rules = [rule("/secret/**", &[])];
let result = withheld_object_oids(&bare, &rules, true, OWNER, None);
assert!(
result.is_err(),
"a ref that peels to a tree must fail the withheld walk closed (Err), not a partial set"
);
}
#[test]
fn allowed_set_excludes_dangling_blob_for_every_caller() {
// #126: a blob written via `git hash-object -w` but never referenced has
+25 -2
View File
@@ -676,7 +676,20 @@ async fn warm_candidates(
&repo.owner_did,
&repo.name,
) {
Ok(p) if p.is_dir() => out.push((repo, created_at_key, p)),
// A tree whose publish is unresolved is filtered HERE, before the
// candidate is ever probed: discovery records the repo as a source
// for a CID, and refs that may never become durable must not become
// durable provenance. This is a different filter from the `quarantined`
// status flag dropped at candidate-load time; that one is an operator
// decision about a mirror, this one is "the store cannot confirm what
// is on disk right now". An unresolved candidate falls into the arm
// below and is accounted exactly as a cold one: absent, not evidence.
Ok(p)
if p.is_dir()
&& !crate::git::repo_store::live_tree_publish_unresolved(p.as_path()) =>
{
out.push((repo, created_at_key, p.into_path_buf()))
}
Ok(_) => {}
Err(e) => {
tracing::warn!(repo_id = %repo.id, err = %e, "sweep discovery: rejected unsafe repo path");
@@ -1015,6 +1028,16 @@ async fn sweep_pass(
row_retryable = true;
continue;
}
// The repo is on disk but its last publish is unresolved, so the refs it
// holds may never become durable. The sweep does not merely read here: it
// rewrites the legacy provider CID from whatever these objects say, which
// is state the resolver then serves. Skip, and count it retryable rather
// than settled: an unresolved publish resolves one way or the other, so
// the row is worth walking again, unlike a repo that is simply gone.
if crate::git::repo_store::live_tree_publish_unresolved(repo_path.as_path()) {
row_retryable = true;
continue;
}
// The sweep holds no pin permit and has no batch to overrun, so the plain
// `git_timeout` is the right budget here.
row_read_attempted = true;
@@ -1602,7 +1625,7 @@ pub(crate) fn batch_budget_gate(
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
/// applies `visibility_pack::replicable_objects` on the delta path or the
/// `..._fail_closed` filter on the full-scan path before calling, so this
/// function never sees a withheld blob. `repo_path` is still needed to read each
/// function never sees a withheld blob or withheld-subtree tree. `repo_path` is still needed to read each
/// object's bytes, and `git_bin` names the binary those reads run: the production
/// callers pass the literal `"git"`, and a test passes a fake so the loop's own bound
/// can be driven with a git that never answers. `repo_id` records the pin's provenance
File diff suppressed because it is too large. Load diff
+127 -3
View File
@@ -1,4 +1,4 @@
//! Prometheus metrics for the gitlawb node.
//! Prometheus metrics for the Twigpine node.
//!
//! Exposes a small, deliberately conservative set of counters and histograms
//! that cover the questions an operator actually asks of a live node:
@@ -11,6 +11,11 @@
//! `gitlawb_sync_queue_processed_total{status}`
//! * are webhooks reaching their endpoints? —
//! `gitlawb_webhook_deliveries_total{result}`
//! * is inbound gossip being admitted or shed, and for which reason?
//! `gitlawb_gossip_ingest_events_total{outcome}`
//! * is the gossip replay seen-set full, and therefore admitting events it
//! cannot deduplicate?
//! `gitlawb_gossip_replay_guard_saturated_total`
//! * how big are the packs we're sending and receiving? —
//! `gitlawb_pack_size_bytes`
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
@@ -33,8 +38,8 @@
use std::sync::OnceLock;
use prometheus::{
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
TextEncoder,
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
Registry, TextEncoder,
};
/// The single, process-wide metrics registry. Initialized by [`init`].
@@ -49,8 +54,15 @@ static AUTH_SUCCESSES: OnceLock<IntCounterVec> = OnceLock::new();
static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
static GOSSIP_REPLAY_GUARD_SATURATED: OnceLock<IntCounter> = OnceLock::new();
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
/// 1 when the node started without its p2p identity because the key storage
/// could not be used. The node keeps serving HTTP in that state and /health
/// stays green, so without this the outage is visible only to whoever reads
/// the startup log.
static P2P_KEY_LOAD_FAILED: OnceLock<IntGauge> = OnceLock::new();
/// One-time initializer. Builds the registry, registers every metric,
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
@@ -168,6 +180,33 @@ fn init_inner(version: &str, node_did: &str) {
.set(webhook_deliveries)
.expect("set WEBHOOK_DELIVERIES once");
let gossip_ingest = IntCounterVec::new(
Opts::new(
"gitlawb_gossip_ingest_events_total",
"Total inbound gossip ref-update events and what the ingest path decided about each",
),
&["outcome"],
)
.expect("gitlawb_gossip_ingest_events_total definition");
registry
.register(Box::new(gossip_ingest.clone()))
.expect("register gitlawb_gossip_ingest_events_total");
GOSSIP_INGEST
.set(gossip_ingest)
.expect("set GOSSIP_INGEST once");
let gossip_replay_guard_saturated = IntCounter::with_opts(Opts::new(
"gitlawb_gossip_replay_guard_saturated_total",
"Inbound gossip ref-updates admitted without being recorded because the replay seen-set was at capacity",
))
.expect("gitlawb_gossip_replay_guard_saturated_total definition");
registry
.register(Box::new(gossip_replay_guard_saturated.clone()))
.expect("register gitlawb_gossip_replay_guard_saturated_total");
GOSSIP_REPLAY_GUARD_SATURATED
.set(gossip_replay_guard_saturated)
.expect("set GOSSIP_REPLAY_GUARD_SATURATED once");
let pack_size = Histogram::with_opts(
HistogramOpts::new(
"gitlawb_pack_size_bytes",
@@ -202,6 +241,18 @@ fn init_inner(version: &str, node_did: &str) {
.set(peers_connected)
.expect("set PEERS_CONNECTED once");
let p2p_key_load_failed = IntGauge::with_opts(Opts::new(
"gitlawb_p2p_identity_key_load_failed",
"1 if the node is running without p2p because its identity key could not be loaded",
))
.expect("gitlawb_p2p_identity_key_load_failed definition");
registry
.register(Box::new(p2p_key_load_failed.clone()))
.expect("register gitlawb_p2p_identity_key_load_failed");
P2P_KEY_LOAD_FAILED
.set(p2p_key_load_failed)
.expect("set P2P_KEY_LOAD_FAILED once");
REGISTRY
.set(registry)
.expect("set REGISTRY once (init must be called exactly once)");
@@ -270,6 +321,66 @@ pub fn record_webhook_delivery(result: &str) {
}
}
/// Record what the gossip ingest path decided about one inbound ref-update.
/// `outcome` ∈ {accepted, unsigned_admitted, write_failed, rejected,
/// source_rate_limited, author_rate_limited, unsigned_source_rate_limited,
/// replayed, stale_timestamp}.
///
/// `accepted` is reserved for signature-verified events. An unsigned event that
/// survives the rolling-upgrade window is `unsigned_admitted`, so the
/// authenticated-admission rate is not silently padded by legacy compatibility
/// traffic; an operator can tell whether the fleet still relies on the
/// compatibility allowance.
///
/// The three shed reasons stay separate labels rather than one `rate_limited`
/// because they answer different operator questions: `source_rate_limited` is a
/// forwarding peer over the pre-parse brake, `unsigned_source_rate_limited` is
/// that same forwarder relaying unproven traffic, and `author_rate_limited`
/// names a principal a signature actually proved. Collapsing them would make a
/// flood of unauthenticated garbage indistinguishable from one registered peer
/// pushing too hard, which is exactly the distinction an alert needs.
///
/// The label is the variant, never the free-form reason string that
/// `Rejected`/`WriteFailed` carry: those are attacker-influenced and would blow
/// up the label cardinality of a process-wide registry.
pub fn record_gossip_ingest(outcome: &str) {
if let Some(c) = GOSSIP_INGEST.get() {
c.with_label_values(&[outcome]).inc();
}
}
/// Record one inbound gossip ref-update that the replay seen-set could not
/// record because it was at capacity.
///
/// Label-less on purpose: there is one degradation mode and nothing about it
/// varies per event, so a label would only add cardinality.
///
/// This is an alert-worthy signal, not a debug counter, and it is the ONLY way
/// the degraded state is visible. At capacity the guard fails open, which means
/// the event is admitted and `gitlawb_gossip_ingest_events_total{outcome=
/// "accepted"}` counts it exactly as it counts a healthy admission. Fail-open is
/// the right policy (a saturated set that dropped all fresh gossip would convert
/// a loud resource attack into quiet mesh-wide censorship), but it is not a free
/// one: while this counter is moving, a captured signature can again be replayed
/// against the freshness window's full 10 minutes, spending a `peer_exists`
/// round trip and a debit from the victim author's budget on every replay.
pub fn record_gossip_replay_guard_saturated() {
if let Some(c) = GOSSIP_REPLAY_GUARD_SATURATED.get() {
c.inc();
}
}
/// Test-only: current `gitlawb_gossip_replay_guard_saturated_total` value (0 if
/// the registry is not initialized). Lets the seen-set tests assert the
/// saturation signal fires without scraping the encoded text.
#[cfg(test)]
pub fn replay_guard_saturated_count_for_test() -> u64 {
GOSSIP_REPLAY_GUARD_SATURATED
.get()
.map(|c| c.get())
.unwrap_or(0)
}
/// Record a pack body size observation (bytes).
pub fn observe_pack_size(bytes: f64) {
if let Some(h) = PACK_SIZE.get() {
@@ -284,6 +395,13 @@ pub fn set_peers_connected(count: i64) {
}
}
/// Record whether the node is running without its p2p identity.
pub fn set_p2p_key_load_failed(failed: bool) {
if let Some(g) = P2P_KEY_LOAD_FAILED.get() {
g.set(i64::from(failed));
}
}
/// Encode the registry as the standard Prometheus text exposition format.
/// Returns an error if `init` was never called.
pub fn encode() -> Result<String, prometheus::Error> {
@@ -365,6 +483,12 @@ mod tests {
record_auth_failure("test/route", "test_reason");
record_sync_processed("done");
record_webhook_delivery("ok");
// `record_gossip_replay_guard_saturated` is deliberately NOT called
// here. It is a label-less process-wide counter and the seen-set test
// that asserts it asserts a before/after delta, so an increment from a
// second test running concurrently in this binary would be a flake with
// no diagnostic value.
record_gossip_ingest("accepted");
observe_pack_size(1024.0);
set_peers_connected(0);
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -70,7 +70,8 @@ pub async fn pin_object(
/// Pin any of the given candidate git objects that haven't yet been sent to
/// Pinata.
///
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
/// `object_list` is the already-withheld-filtered OID set to pin (blobs and
/// withheld-subtree trees already dropped): the caller
/// applies `visibility_pack::replicable_objects` on the delta path or the
/// `..._fail_closed` filter on the full-scan path before calling. `repo_path` is
/// still needed to read each object's bytes, and `git_bin` names the binary those
+17
View File
@@ -143,6 +143,23 @@ impl RateLimiter {
false
}
/// The configured key ceiling. Tests use it to assert that a limiter a
/// production factory builds is actually bounded, which flooding the real
/// ceiling in distinct keys would be far too slow to show.
#[cfg(test)]
pub fn max_keys(&self) -> usize {
self.max_keys
}
/// The configured per-key request cap. Tests use it to assert that a
/// limiter a production factory builds carries the cap it is documented
/// with; re-asserting the constant on its own cannot tell whether the
/// factory passed that constant or a different one.
#[cfg(test)]
pub fn max_requests(&self) -> usize {
self.max_requests
}
pub async fn cleanup(&self) {
let now = Instant::now();
let mut state = self.state.lock().await;
+58 -9
View File
@@ -42,17 +42,37 @@ async fn graphql_playground() -> impl IntoResponse {
))
}
/// Pre-auth body-buffer ceiling for every signed route group except the git write
/// routes. Deliberately equal to axum's `DefaultBodyLimit` (2 MB), which is what
/// these groups' `Bytes`/`Json` extractors already enforce: that default works
/// through a request extension the extractors consult, and `require_signature`
/// collects the raw body itself, so it never saw the cap. Matching the number
/// keeps this change behavior-preserving on the response path (nothing that used
/// to be accepted starts failing) and purely a memory fix.
pub const SIGNED_BODY_LIMIT: usize = 2 * 1024 * 1024;
/// Applies the standard auth middleware pair to a router: HTTP Signature verification
/// followed by UCAN chain validation. The two layers run in this order for every
/// matched request: `require_signature` first (sets `AuthenticatedDid`), then
/// `require_ucan_chain` (reads it).
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
///
/// `body_limit` bounds the request body `require_signature` buffers before it has
/// authenticated anything (see [`auth::SignatureBodyLimit`]): `max_pack_bytes` for
/// the git write group, [`SIGNED_BODY_LIMIT`] for every other group.
fn add_auth_layers(
router: Router<AppState>,
state: AppState,
body_limit: usize,
) -> Router<AppState> {
router
.layer(middleware::from_fn_with_state(
state,
auth::require_ucan_chain,
))
.layer(middleware::from_fn(auth::require_signature))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(body_limit),
auth::require_signature,
))
}
pub fn build_router(state: AppState) -> Router {
@@ -63,7 +83,10 @@ pub fn build_router(state: AppState) -> Router {
// Attach the verified DID to /graphql when a signature is present. The
// layer covers only routes added before it, so /graphql/ws (added after,
// read-only subscriptions) stays open.
.layer(middleware::from_fn(auth::optional_signature))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
// ── Task routes (write — require HTTP Signature) ───────────────────────
@@ -74,6 +97,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
state.clone(),
SIGNED_BODY_LIMIT,
);
// ── Task routes (read — open) ──────────────────────────────────────────
@@ -107,6 +131,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(limiter)),
state.clone(),
SIGNED_BODY_LIMIT,
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(create_ip_limiter));
@@ -181,6 +206,7 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::delete(agents::deregister_agent),
),
state.clone(),
SIGNED_BODY_LIMIT,
);
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
@@ -205,6 +231,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit)),
state.clone(),
pack_limit,
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(push_limiter));
@@ -225,7 +252,10 @@ pub fn build_router(state: AppState) -> Router {
};
let ipfs_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.layer(middleware::from_fn(auth::optional_signature))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(ipfs_limiter))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
@@ -258,6 +288,7 @@ pub fn build_router(state: AppState) -> Router {
post(bounties::dispute_bounty),
),
state.clone(),
SIGNED_BODY_LIMIT,
);
// ── Bounty routes (read — open) ──────────────────────────────────────
@@ -273,12 +304,16 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/agents/{did}/bounties",
get(bounties::agent_bounty_stats),
)
.layer(middleware::from_fn(auth::optional_signature));
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
// ── Profile routes (write — require HTTP Signature) ─────────────────
let profile_write_routes = add_auth_layers(
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
SIGNED_BODY_LIMIT,
);
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
@@ -293,6 +328,7 @@ pub fn build_router(state: AppState) -> Router {
post(issues::create_issue_comment),
),
state.clone(),
SIGNED_BODY_LIMIT,
);
// ── Peer discovery routes ─────────────────────────────────────────────
@@ -311,6 +347,7 @@ pub fn build_router(state: AppState) -> Router {
let sync_trigger_routes = add_auth_layers(
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
state.clone(),
SIGNED_BODY_LIMIT,
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
@@ -327,9 +364,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/peers/announce", post(peers::announce))
.route("/api/v1/sync/notify", post(peers::notify_sync));
peer_write_routes = if state.config.require_signed_peer_writes {
add_auth_layers(peer_write_routes, state.clone())
add_auth_layers(peer_write_routes, state.clone(), SIGNED_BODY_LIMIT)
} else {
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
peer_write_routes.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
};
let peer_write_routes = peer_write_routes
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
@@ -428,7 +468,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/repos/{owner}/{repo}/replicas",
get(replicas::list_replicas),
)
.layer(middleware::from_fn(auth::optional_signature));
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
// large pack responses from the server don't get truncated on the client side.
@@ -460,7 +503,13 @@ pub fn build_router(state: AppState) -> Router {
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::optional_signature));
// `pack_limit`, not SIGNED_BODY_LIMIT: git-upload-pack POSTs live here and
// the group has deliberately raised its ceiling, so the pre-auth buffer for
// a signed fetch negotiation must match rather than narrow it.
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(pack_limit),
auth::optional_signature,
));
// ── Meta ──────────────────────────────────────────────────────────────
let meta_routes = Router::new()
+5
View File
@@ -79,6 +79,10 @@ pub struct AppState {
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
/// push path throttles on the resolved client IP instead.
pub push_rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for `close_issue`'s pre-lock snapshot path.
/// Distinct from `push_rate_limiter` so a flood of close attempts cannot
/// drain the receive-pack budget for the same source IP.
pub close_issue_rate_limiter: RateLimiter,
/// Per-client-IP ROUTE brake for `GET /ipfs/{cid}`: charged ONCE per request by the
/// `rate_limit_by_ip` middleware (server.rs), never inside the handler. It bounds
/// request RATE (the "requests per hour" contract of `GITLAWB_IPFS_RATE_LIMIT`) on
@@ -342,6 +346,7 @@ impl AppState {
self.rate_limiter.cleanup().await;
self.create_ip_rate_limiter.cleanup().await;
self.push_rate_limiter.cleanup().await;
self.close_issue_rate_limiter.cleanup().await;
self.ipfs_rate_limiter.cleanup().await;
self.ipfs_work_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -225,7 +225,8 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
let repo_store = src("git/repo_store.rs");
let rel_start = repo_store
.find("pub async fn release(mut self")
.find("pub async fn release(self")
.or_else(|| repo_store.find("pub async fn release(mut self"))
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
let rel_end = repo_store[rel_start..]
.find("impl Drop for RepoWriteGuard")
@@ -0,0 +1,637 @@
//! Which p2p key-storage failures stop the node, and which leave HTTP up.
//!
//! The two domains are decided by different code at different phases, so the
//! only honest proof is at the process boundary: spawn the real binary and
//! watch what it does. A helper's returned error says nothing about whether
//! `main` treated it as fatal.
//!
//! Every row also asserts the key tree is byte-identical afterwards. A refusal
//! that mutates storage on its way out is its own defect, and a returned error
//! cannot show that.
//!
//! No database: `DATABASE_URL` points at a closed port on purpose, so a row
//! that reaches the degraded server proves the outcome is observable without
//! Postgres. That is what makes the port-zero no-I/O guarantee testable at all.
#![cfg(unix)]
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
/// Wall clock, never an iteration count: a fixed loop count starves on a slow
/// or loaded machine and turns a real pass into a flake.
const DEADLINE: Duration = Duration::from_secs(15);
/// Kills the child on every exit path, including a panicking assertion.
///
/// Without this a failing row leaves a node behind that retries its database
/// connection forever, holding the row's tempdir open. The first run of this
/// file did exactly that, and the leak is invisible until something else in
/// the suite gets slow.
struct ChildGuard(Child);
impl ChildGuard {
/// The tracing subscriber writes to STDOUT; only anyhow's final error
/// print goes to stderr. A row that watches one stream sees half the
/// story, so both are read.
fn take_stdout(&mut self) -> std::process::ChildStdout {
self.0.stdout.take().expect("stdout piped")
}
fn take_stderr(&mut self) -> std::process::ChildStderr {
self.0.stderr.take().expect("stderr piped")
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// `(path, is_dir, is_symlink, mode, len)` for everything under `root`, sorted.
/// Taken before and after each row so a refusal that creates, chmods, or
/// deletes anything fails the row.
fn snapshot(root: &Path) -> Vec<String> {
fn walk(dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let Ok(md) = std::fs::symlink_metadata(&p) else {
continue;
};
out.push(format!(
"{} dir={} link={} mode={:04o} len={}",
p.display(),
md.is_dir(),
md.is_symlink(),
md.permissions().mode() & 0o7777,
if md.is_file() { md.len() } else { 0 }
));
if md.is_dir() && !md.is_symlink() {
walk(&p, out);
}
}
}
let mut out = Vec::new();
walk(root, &mut out);
out.sort();
out
}
struct Row {
/// Sandbox the row owns: HOME, repos, cwd and the key tree all live here.
home: tempfile::TempDir,
}
impl Row {
/// The only directory the no-mutation assertions watch.
fn tree(&self) -> PathBuf {
self.home.path().join("keytree")
}
}
impl Row {
fn new() -> Row {
let home = tempfile::tempdir().expect("tempdir");
std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700))
.expect("chmod home");
// Created here, before any row takes its `before` snapshot, so the
// no-mutation assertions measure what the NODE did and not what this
// harness set up.
std::fs::create_dir_all(home.path().join("repos")).expect("repos dir");
// The p2p key tree lives in its own subdirectory so the no-mutation
// assertions can watch exactly it. HOME also holds `.gitlawb`, which
// the node creates for its NODE identity on every start; measuring the
// whole home would read that legitimate write as a p2p storage
// mutation.
let tree = home.path().join("keytree");
std::fs::create_dir(&tree).expect("key tree");
std::fs::set_permissions(&tree, std::fs::Permissions::from_mode(0o700))
.expect("chmod key tree");
Row { home }
}
fn spawn(&self, p2p_key: &str, p2p_port: &str, cwd: &Path) -> ChildGuard {
self.spawn_env(p2p_key, p2p_port, cwd, &[])
}
fn spawn_env(
&self,
p2p_key: &str,
p2p_port: &str,
cwd: &Path,
extra: &[(&str, &str)],
) -> ChildGuard {
let repos = self.home.path().join("repos");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gitlawb-node"));
cmd.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
// The subscriber is built from the default env filter, so with a
// cleared environment the node logs nothing at all and every
// assertion here reads as a timeout with empty output. The rows
// are about which log lines appear, so the filter is part of the
// fixture, not incidental setup.
.env("RUST_LOG", "info")
.env("HOME", self.home.path())
.env("GITLAWB_REPOS_DIR", &repos)
.env("GITLAWB_HOST", "127.0.0.1")
// Ephemeral: the real port is read back from the ready log.
.env("GITLAWB_PORT", "0")
// A closed port, so the node stays on the degraded path for the
// whole row instead of ever reaching a database.
.env("DATABASE_URL", "postgres://127.0.0.1:1/nonexistent")
.env("GITLAWB_P2P_PORT", p2p_port)
.env("GITLAWB_P2P_KEY", p2p_key)
.env("GITLAWB_METRICS_ADDR", "")
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra {
cmd.env(k, v);
}
cmd.spawn().map(ChildGuard).expect("spawn gitlawb-node")
}
}
/// Read the child's stderr until `needle` appears, the stream ends, or the
/// deadline passes. Returns everything read.
///
/// The read runs on its own thread feeding a channel, and the deadline is
/// enforced with `recv_timeout`. Checking elapsed time between blocking
/// `read_line` calls does NOT work: a child that goes quiet blocks the read
/// forever and the check never runs, so the deadline looks present and cannot
/// fire. The first version of this file did that and hung for minutes on a row
/// that was supposed to fail in fifteen seconds.
fn read_until(child: &mut ChildGuard, needle: &str) -> (bool, String) {
read_until_all(child, &[needle])
}
/// Read until EVERY needle has appeared, the stream ends, or the deadline
/// passes.
///
/// Waiting on a single line is a race here: the degraded server logs "ready"
/// from a spawned task while the p2p gate logs its verdict from the main task,
/// so whichever the reader stops at first can leave the other uncaptured. That
/// made this file pass or fail depending on scheduling.
fn read_until_all(child: &mut ChildGuard, needles: &[&str]) -> (bool, String) {
let stdout = child.take_stdout();
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
if tx.send(line).is_err() {
break;
}
}
}
}
});
let mut acc = String::new();
let start = Instant::now();
loop {
if needles.iter().all(|n| acc.contains(n)) {
return (true, acc);
}
let left = match DEADLINE.checked_sub(start.elapsed()) {
Some(d) if !d.is_zero() => d,
_ => return (false, acc),
};
match rx.recv_timeout(left) {
Ok(line) => acc.push_str(&line),
// Sender gone: the stream ended. One last check, then give up.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return (needles.iter().all(|n| acc.contains(n)), acc)
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return (false, acc),
}
}
}
fn wait_for_exit(child: &mut ChildGuard) -> (std::process::ExitStatus, String) {
// Both streams: the refusal reason is anyhow's stderr print, while
// "binding HTTP listener" is a tracing line on stdout, and the fatal rows
// assert on one of each.
let mut out = String::new();
let mut o = child.take_stdout();
let mut e = child.take_stderr();
let reader = std::thread::spawn(move || {
let mut buf = String::new();
let _ = o.read_to_string(&mut buf);
buf
});
let _ = e.read_to_string(&mut out);
let status = child.0.wait().expect("wait");
out.push_str(&reader.join().unwrap_or_default());
(status, out)
}
/// Strip ANSI escapes: the subscriber colourises even when piped, which would
/// otherwise sit between `addr=` and the value.
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c2 in chars.by_ref() {
if c2.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
/// A lexically invalid value must stop the process before the listener binds.
fn assert_fatal_before_bind(row: &Row, key: &str, cwd: &Path) {
let before = snapshot(&row.tree());
let mut child = row.spawn(key, "7546", cwd);
let (status, err) = wait_for_exit(&mut child);
assert!(
!status.success(),
"key={key:?} must exit non-zero\n--- stderr ---\n{err}"
);
assert!(
err.contains("invalid configuration"),
"key={key:?} must fail as invalid configuration\n--- stderr ---\n{err}"
);
assert!(
!err.contains("binding HTTP listener"),
"key={key:?} must be refused BEFORE the listener binds\n--- stderr ---\n{err}"
);
assert_eq!(
snapshot(&row.tree()),
before,
"key={key:?}: a configuration refusal must not touch the key tree"
);
}
/// A live storage fault must leave HTTP serving with p2p off, and must not
/// mutate the tree on its way to that verdict.
fn assert_degrades_with_http_up(row: &Row, key: &str, cwd: &Path, label: &str) {
let before = snapshot(&row.tree());
let mut child = row.spawn(key, "7546", cwd);
let (found, log) = read_until_all(
&mut child,
&[
"degraded HTTP server ready",
"failed to load p2p identity key",
],
);
assert!(
found,
"{label}: the node must bind and serve while p2p is off\n--- stderr ---\n{log}"
);
// The operator docs promise a specific signal, so pin it here: the level,
// the stable event name, and the message. A degrade that logs at warn or
// renames the event silently breaks the alert those docs tell operators to
// build, and asserting only the prose would not notice.
let clean_log = strip_ansi(&log);
let signal = clean_log
.lines()
.find(|l| l.contains("failed to load p2p identity key"))
.unwrap_or_else(|| panic!("{label}: the p2p failure must be logged\n{clean_log}"));
assert!(
signal.contains("ERROR"),
"{label}: the p2p failure must be logged at error level, got: {signal}"
);
assert!(
signal.contains("p2p_identity_key_load_failed"),
"{label}: the p2p failure must carry the documented event name, got: {signal}"
);
let clean = strip_ansi(&log);
let addr = clean
.lines()
.find(|l| l.contains("degraded HTTP server ready"))
.and_then(|l| l.rsplit("addr=").next())
.map(|a| {
a.trim()
.trim_start_matches("Some(")
.trim_matches(|c| c == '"' || c == ')')
})
.and_then(|a| a.parse::<std::net::SocketAddr>().ok())
.unwrap_or_else(|| panic!("{label}: could not parse the bound address from {clean}"));
// A served response, any status: the degraded server answers 503, which is
// still proof the port is up and answering.
let mut sock = std::net::TcpStream::connect(addr).expect("connect to the degraded server");
sock.set_read_timeout(Some(DEADLINE)).unwrap();
sock.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.expect("send request");
let mut resp = String::new();
let _ = sock.read_to_string(&mut resp);
assert!(
resp.starts_with("HTTP/1.1 "),
"{label}: the degraded server must answer, got {resp:?}"
);
drop(child);
assert_eq!(
snapshot(&row.tree()),
before,
"{label}: a storage refusal must not mutate the key tree"
);
}
#[test]
fn lexically_invalid_key_paths_stop_the_node_before_it_binds() {
for key in [
"p2p.key",
"./p2p.key",
"a/../p2p.key",
"../p2p.key",
"/p2p.key",
"keys/",
"~/",
"~//etc/p2p.key",
"~/../x/p2p.key",
] {
let row = Row::new();
let cwd = row.home.path().to_path_buf();
assert_fatal_before_bind(&row, key, &cwd);
}
}
#[test]
fn live_storage_faults_leave_http_up_with_p2p_off() {
// Symlinked final parent.
{
let row = Row::new();
let real = row.tree().join("real");
std::fs::create_dir(&real).unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = row.tree().join("keys");
std::os::unix::fs::symlink(&real, &link).unwrap();
let key = link.join("p2p.key");
let cwd = row.home.path().to_path_buf();
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "symlinked parent");
}
// Regular file where the key directory should be.
{
let row = Row::new();
let file = row.tree().join("keys");
std::fs::write(&file, b"not a directory").unwrap();
let key = file.join("p2p.key");
let cwd = row.home.path().to_path_buf();
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "regular-file parent");
}
// Unreadable parent: an inspection error, the purest instance of a live
// fault that used to be reported as invalid configuration.
{
let row = Row::new();
let parent = row.tree().join("locked");
std::fs::create_dir(&parent).unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o000)).unwrap();
let key = parent.join("keys").join("p2p.key");
let cwd = row.home.path().to_path_buf();
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "unreadable parent");
// Restore so the tempdir can be cleaned up.
let _ = std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700));
}
// World-writable non-sticky ancestor: already the degrade class today, kept
// so the row set covers both sides of the policy.
{
let row = Row::new();
let anc = row.tree().join("open");
std::fs::create_dir(&anc).unwrap();
std::fs::set_permissions(&anc, std::fs::Permissions::from_mode(0o777)).unwrap();
let key = anc.join("keys").join("p2p.key");
let cwd = row.home.path().to_path_buf();
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "world-writable ancestor");
}
}
#[test]
fn port_zero_does_no_key_storage_io_at_all() {
// A lexically invalid path that would be fatal with p2p enabled.
{
let row = Row::new();
let before = snapshot(&row.tree());
let cwd = row.home.path().to_path_buf();
let mut child = row.spawn("p2p.key", "0", &cwd);
let (found, log) = read_until(&mut child, "p2p disabled");
assert!(
found,
"port zero must bypass key handling entirely\n--- stderr ---\n{log}"
);
drop(child);
assert_eq!(
snapshot(&row.tree()),
before,
"port zero must not touch the key tree"
);
}
// A hostile tree that would degrade with p2p enabled.
{
let row = Row::new();
let real = row.tree().join("real");
std::fs::create_dir(&real).unwrap();
let link = row.tree().join("keys");
std::os::unix::fs::symlink(&real, &link).unwrap();
let before = snapshot(&row.tree());
let cwd = row.home.path().to_path_buf();
let key: PathBuf = link.join("p2p.key");
let mut child = row.spawn(key.to_str().unwrap(), "0", &cwd);
let (found, log) = read_until(&mut child, "p2p disabled");
assert!(
found,
"port zero must bypass a hostile tree too\n--- stderr ---\n{log}"
);
drop(child);
assert_eq!(
snapshot(&row.tree()),
before,
"port zero must not touch a hostile key tree"
);
}
}
/// A terminal `.` is a directory-valued spelling. Path drops it, so the load
/// path would otherwise chmod the parent and publish a file named `keys`.
#[test]
fn terminal_dot_key_path_is_fatal_and_does_not_mutate() {
// Absent leaf: `/data/keys/.` must not create `keys` as a file or chmod `/data`.
{
let row = Row::new();
let data = row.tree().join("data");
std::fs::create_dir(&data).unwrap();
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap();
let key = format!("{}/keys/.", data.display());
let cwd = row.home.path().to_path_buf();
assert_fatal_before_bind(&row, &key, &cwd);
let mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o755, "rejection must not chmod the parent");
assert!(
!data.join("keys").exists(),
"rejection must not create a file named keys"
);
}
// Existing directory at the would-be leaf: still boot-fatal, directory untouched.
{
let row = Row::new();
let data = row.tree().join("data");
let keys = data.join("keys");
std::fs::create_dir_all(&keys).unwrap();
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap();
let key = format!("{}/.", keys.display());
let cwd = row.home.path().to_path_buf();
assert_fatal_before_bind(&row, &key, &cwd);
assert!(
keys.is_dir(),
"the existing keys directory must remain a directory"
);
let data_mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777;
let keys_mode = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777;
assert_eq!(data_mode, 0o755, "rejection must not chmod the parent");
assert_eq!(
keys_mode, 0o700,
"rejection must not chmod the existing keys dir"
);
}
}
/// GITLAWB_KEY has the same Path-retarget hole as GITLAWB_P2P_KEY: a terminal
/// `.` would publish a file named `keys` under `data`. Fatal before bind, and
/// the parent tree is unchanged. p2p is off so it cannot chmod a shared dir.
#[test]
fn identity_terminal_dot_is_fatal_and_does_not_mutate() {
let row = Row::new();
let data = row.home.path().join("data");
std::fs::create_dir(&data).unwrap();
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap();
let key = format!("{}/keys/.", data.display());
let cwd = row.home.path().to_path_buf();
let mut child = row.spawn_env("p2p.key", "0", &cwd, &[("GITLAWB_KEY", key.as_str())]);
let (status, err) = wait_for_exit(&mut child);
assert!(
!status.success(),
"GITLAWB_KEY={key:?} must exit non-zero\n--- stderr ---\n{err}"
);
assert!(
err.contains("invalid configuration"),
"GITLAWB_KEY={key:?} must fail as invalid configuration\n--- stderr ---\n{err}"
);
assert!(
!err.contains("binding HTTP listener"),
"GITLAWB_KEY={key:?} must be refused BEFORE the listener binds\n--- stderr ---\n{err}"
);
let mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o755, "rejection must not chmod the parent");
assert!(
!data.join("keys").exists(),
"rejection must not create a file named keys"
);
}
/// An existing 0755 identity parent is usable and must not be chmodded. p2p is
/// off so a default `~/.gitlawb/p2p.key` cannot tighten the same directory.
#[test]
fn identity_existing_0755_parent_is_not_chmodded_on_boot() {
let row = Row::new();
let iddir = row.home.path().join("idparent");
std::fs::create_dir(&iddir).unwrap();
std::fs::set_permissions(&iddir, std::fs::Permissions::from_mode(0o755)).unwrap();
let key = iddir.join("identity.pem");
let cwd = row.home.path().to_path_buf();
let mut child = row.spawn_env(
"p2p.key",
"0",
&cwd,
&[("GITLAWB_KEY", key.to_str().unwrap())],
);
let (found, log) = read_until(&mut child, "degraded HTTP server ready");
assert!(
found,
"a 0755 identity parent must still boot\n--- stderr ---\n{log}"
);
drop(child);
let mode = std::fs::metadata(&iddir).unwrap().permissions().mode() & 0o7777;
assert_eq!(
mode, 0o755,
"boot must not chmod the existing identity parent"
);
assert_eq!(
std::fs::metadata(&key).unwrap().permissions().mode() & 0o7777,
0o600,
"the identity key must be owner-only"
);
}
/// Creating into a world-writable identity parent is refused before bind, and
/// the directory is left untouched.
#[test]
fn identity_writable_parent_is_fatal_and_unchanged() {
let row = Row::new();
let iddir = row.home.path().join("idparent");
std::fs::create_dir(&iddir).unwrap();
std::fs::set_permissions(&iddir, std::fs::Permissions::from_mode(0o777)).unwrap();
let key = iddir.join("identity.pem");
let cwd = row.home.path().to_path_buf();
let mut child = row.spawn_env(
"p2p.key",
"0",
&cwd,
&[("GITLAWB_KEY", key.to_str().unwrap())],
);
let (status, err) = wait_for_exit(&mut child);
assert!(
!status.success(),
"a writable identity parent must exit non-zero\n--- stderr ---\n{err}"
);
assert!(
!err.contains("binding HTTP listener"),
"a writable identity parent must be refused BEFORE the listener binds\n--- stderr ---\n{err}"
);
let mode = std::fs::metadata(&iddir).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o777, "refusal must not chmod the parent");
assert!(!key.exists(), "refusal must not publish the identity key");
}
/// A 2700 (setgid, owner rwx) p2p key directory is repairable: boot, tighten
/// to 0700, HTTP still comes up.
#[test]
fn p2p_setgid_key_directory_is_tightened_and_http_comes_up() {
let row = Row::new();
let keys = row.tree().join("keys");
std::fs::create_dir(&keys).unwrap();
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o2700)).unwrap();
let key = keys.join("p2p.key");
let cwd = row.home.path().to_path_buf();
let mut child = row.spawn(key.to_str().unwrap(), "7546", &cwd);
let (found, log) = read_until_all(
&mut child,
&["degraded HTTP server ready", "generated new p2p identity"],
);
assert!(
found,
"a 2700 key directory must boot and generate a key\n--- stderr ---\n{log}"
);
drop(child);
let mode = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777;
assert_eq!(
mode, 0o700,
"2700 must be normalized to 0700, found {mode:04o}"
);
}
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gl"
description = "The gitlawb CLI — identity management, node control, MCP server"
description = "The Twigpine CLI — identity management, node control, MCP server"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
@@ -42,3 +42,4 @@ alloy = { version = "1", default-features = false, features = [
mockito = "1"
tempfile = "3"
tokio = { workspace = true }
ed25519-dalek = { workspace = true }
+10 -2
View File
@@ -17,7 +17,11 @@ pub struct AgentArgs {
pub enum AgentCmd {
/// List agents registered on a node
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Filter by capability (e.g. git:push)
#[arg(long)]
@@ -29,7 +33,11 @@ pub enum AgentCmd {
Show {
/// Agent DID or short key segment
did: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
}
+40 -8
View File
@@ -39,7 +39,11 @@ pub enum BountyCmd {
/// Deadline in seconds (default 7 days)
#[arg(long)]
deadline: Option<i64>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -51,7 +55,11 @@ pub enum BountyCmd {
/// Filter by status: open, claimed, submitted, completed
#[arg(long)]
status: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -60,7 +68,11 @@ pub enum BountyCmd {
Show {
/// Bounty ID
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -72,7 +84,11 @@ pub enum BountyCmd {
/// Your wallet address for payout
#[arg(long)]
wallet: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -84,7 +100,11 @@ pub enum BountyCmd {
/// Pull request ID
#[arg(long)]
pr: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -96,7 +116,11 @@ pub enum BountyCmd {
/// On-chain payout tx hash
#[arg(long)]
tx_hash: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -105,14 +129,22 @@ pub enum BountyCmd {
Cancel {
/// Bounty ID
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Show bounty stats and leaderboard
Stats {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
}
+10 -2
View File
@@ -26,7 +26,11 @@ pub enum CertCmd {
List {
/// Repository in <owner>/<repo> or <repo> format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -37,7 +41,11 @@ pub enum CertCmd {
repo: String,
/// Certificate ID
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+6 -2
View File
@@ -15,7 +15,11 @@ pub struct ChangelogArgs {
/// Maximum number of events to show
#[arg(long, default_value = "20")]
pub limit: usize,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
pub node: String,
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -31,7 +35,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
};
if repo.is_empty() {
anyhow::bail!("no repo specified — pass <repo> or run from inside a gitlawb repo");
anyhow::bail!("no repo specified — pass <repo> or run from inside a Twigpine repo");
}
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
+7 -3
View File
@@ -1,4 +1,4 @@
//! `gl clone`: clean partial clone of a gitlawb repo with private subtrees.
//! `gl clone`: clean partial clone of a Twigpine repo with private subtrees.
//!
//! A repo may withhold blob content under some path globs from the caller
//! (Phase 3). The resulting pack is not closed under reachability, so a stock
@@ -76,7 +76,11 @@ pub struct CloneArgs {
#[arg(long)]
pub branch: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
pub node: String,
/// Arweave gateway for B3 manifest discovery/fetch when a node cannot supply
@@ -574,7 +578,7 @@ fn merge_manifests(
.collect()
}
/// Option B3 fallback recovery, with no dependency on a gitlawb node API. Query
/// Option B3 fallback recovery, with no dependency on a Twigpine node API. Query
/// the Arweave gateway for this repo's encrypted manifests, merge them, and for
/// each blob still missing locally that the caller can decrypt, pull the envelope
/// from a public IPFS gateway, decrypt, and install it as a loose object. Returns
+14 -14
View File
@@ -1,10 +1,10 @@
//! `gl doctor` — check your gitlawb installation and connectivity.
//! `gl doctor` — check your Twigpine installation and connectivity.
//!
//! Checks:
//! 1. Identity — ~/.gitlawb/identity.pem exists, DID parseable
//! 2. Registration — ~/.gitlawb/ucan.json exists (registered with a node)
//! 3. GITLAWB_NODE — env var is set to a non-localhost URL
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.gitlawb.com)
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.twigpine.com)
//! 5. git helper — git-remote-gitlawb is in PATH
//! 6. git — git is installed
//! 7. version — gl is up to date with latest GitHub release
@@ -15,7 +15,7 @@ use std::path::PathBuf;
use crate::http::NodeClient;
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
const PUBLIC_NODE: &str = "https://node.twigpine.com";
const GITHUB_API_BASE: &str = "https://api.github.com";
#[derive(Args)]
@@ -70,7 +70,7 @@ impl Check {
}
pub async fn run(args: DoctorArgs) -> Result<()> {
println!("gl doctor — checking your gitlawb setup");
println!("gl doctor — checking your Twigpine setup");
println!();
let dir = args.dir.unwrap_or_else(|| {
@@ -160,7 +160,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
checks.push(Check::fail(
"GITLAWB_NODE",
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
"export GITLAWB_NODE=https://node.gitlawb.com",
"export GITLAWB_NODE=https://node.twigpine.com",
));
}
}
@@ -187,7 +187,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
"node is v{version} but gl is v{gl_ver} — your CLI may be missing \
features (signing / iCaptcha) this node requires"
),
"upgrade gl: curl -sSf https://gitlawb.com/install.sh | sh",
"upgrade gl: curl -sSf https://twigpine.com/install.sh | sh",
));
}
}
@@ -241,7 +241,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
checks.push(Check::fail(
"git-remote-gitlawb",
"not found in PATH — gitlawb:// clone/push will not work",
"curl -sSf https://gitlawb.com/install.sh | sh",
"curl -sSf https://twigpine.com/install.sh | sh",
));
}
@@ -408,7 +408,7 @@ fn which_in_path(name: &str) -> bool {
.unwrap_or(false)
}
/// Fetch the latest release tag from Gitlawb/node (the actual release repo — see install.sh's
/// Fetch the latest release tag from Twigpine/node (the actual release repo — see install.sh's
/// `GITLAWB_RELEASE_REPO` default) and compare to current version.
async fn check_version(current: &'static str, github_api_base: &str) -> Check {
let client = match reqwest::Client::builder()
@@ -422,7 +422,7 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
let resp = match client
.get(format!(
"{github_api_base}/repos/Gitlawb/node/releases/latest"
"{github_api_base}/repos/Twigpine/node/releases/latest"
))
.send()
.await
@@ -457,7 +457,7 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
Check::warn(
"version",
format!("v{current} → v{tag} available"),
"curl -sSf https://gitlawb.com/install.sh | sh",
"curl -sSf https://twigpine.com/install.sh | sh",
)
} else {
Check::pass("version", format!("v{current} (up to date)"))
@@ -550,7 +550,7 @@ mod tests {
assert!(is_loopback_url("http://localhost:7545"));
assert!(is_loopback_url("http://[::1]:7545"));
assert!(!is_loopback_url("https://localhost.example"));
assert!(!is_loopback_url("https://node.gitlawb.com/localhost"));
assert!(!is_loopback_url("https://node.twigpine.com/localhost"));
assert!(!is_loopback_url("not a url"));
}
@@ -603,7 +603,7 @@ mod tests {
async fn test_check_version_queries_gitlawb_node_releases() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.mock("GET", "/repos/Twigpine/node/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name":"v9.9.9"}"#)
@@ -619,7 +619,7 @@ mod tests {
async fn test_check_version_up_to_date() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.mock("GET", "/repos/Twigpine/node/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name":"v0.1.0"}"#)
@@ -635,7 +635,7 @@ mod tests {
async fn test_check_version_http_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.mock("GET", "/repos/Twigpine/node/releases/latest")
.with_status(403)
.create_async()
.await;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Signed HTTP client for gitlawb API calls (async).
//! Signed HTTP client for Twigpine API calls (async).
//!
//! Writes are signed with RFC 9421 HTTP Signatures. When the node gates a write
//! behind iCaptcha (HTTP 403 `icaptcha_proof_required`, advertised via the
+1 -1
View File
@@ -154,7 +154,7 @@ async fn cmd_new_with_reader(
println!(" DID: {did}");
println!(" Key: {}", path.display());
println!();
println!(" Your DID is your identity on the gitlawb network.");
println!(" Your DID is your identity on the Twigpine network.");
println!(" Keep your key file safe — it cannot be recovered if lost.");
Ok(())
+7 -3
View File
@@ -1,7 +1,7 @@
//! `gl init` — zero-to-push in one command.
//!
//! Detects or initializes a git repo, ensures an identity exists,
//! registers with the node, creates a remote repo, adds the gitlawb
//! registers with the node, creates a remote repo, adds the Twigpine
//! remote, and pushes.
use anyhow::{Context, Result};
@@ -19,7 +19,11 @@ pub struct InitArgs {
pub name: Option<String>,
/// Node URL to register with
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
@@ -151,7 +155,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
println!(" Repository created.");
}
// 5. Add gitlawb remote
// 5. Add Twigpine remote
let did_short = did.to_string();
let did_short = did_short.split(':').next_back().unwrap_or(&did_short);
let remote_url = format!("gitlawb://{did_short}/{repo_name}");
+11 -3
View File
@@ -1,6 +1,6 @@
//! `gl ipfs` — IPFS pin management commands.
//!
//! Communicates with the gitlawb node to list pinned CIDs and retrieve git
//! Communicates with the Twigpine node to list pinned CIDs and retrieve git
//! objects by their content-addressed CID.
use std::path::PathBuf;
@@ -22,7 +22,11 @@ pub struct IpfsArgs {
pub enum IpfsCmd {
/// List all CIDs pinned to the node's local IPFS daemon
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
@@ -69,7 +73,11 @@ pub enum IpfsCmd {
Get {
/// The CIDv1 string (e.g. bafkrei...)
cid: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
+30 -6
View File
@@ -32,7 +32,11 @@ pub enum IssueCmd {
/// Issue body (optional)
#[arg(long, short)]
body: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -41,7 +45,11 @@ pub enum IssueCmd {
List {
/// Repository in <owner>/<repo> or <repo> format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -52,7 +60,11 @@ pub enum IssueCmd {
repo: String,
/// Issue ID
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -63,7 +75,11 @@ pub enum IssueCmd {
repo: String,
/// Issue ID
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -74,7 +90,11 @@ pub enum IssueCmd {
id: String,
#[arg(long, short)]
body: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -83,7 +103,11 @@ pub enum IssueCmd {
Comments {
repo: String,
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+7 -7
View File
@@ -38,7 +38,7 @@ mod whoami;
#[derive(Parser)]
#[command(
name = "gl",
about = "gitlawb CLI — identity, repos, MCP server",
about = "Twigpine CLI — identity, repos, MCP server",
version,
propagate_version = true
)]
@@ -49,16 +49,16 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Manage your gitlawb identity (DID + keypair)
/// Manage your Twigpine identity (DID + keypair)
Identity {
#[command(subcommand)]
cmd: identity::IdentityCmd,
},
/// Register this agent with a gitlawb node
/// Register this agent with a Twigpine node
Register(register::RegisterArgs),
/// Clone a gitlawb repo, handling private subtrees cleanly
/// Clone a Twigpine repo, handling private subtrees cleanly
Clone(clone::CloneArgs),
/// Manage repositories
@@ -85,10 +85,10 @@ enum Commands {
/// Manage webhooks for a repository
Webhook(webhook::WebhookArgs),
/// Mirror a public GitHub/GitLab/any-git repo into gitlawb
/// Mirror a public GitHub/GitLab/any-git repo into Twigpine
Mirror(mirror::MirrorArgs),
/// MCP server — expose gitlawb tools to LLM agents
/// MCP server — expose Twigpine tools to LLM agents
Mcp(mcp::McpArgs),
/// Sync repos from peer nodes (HTTP fallback for p2p gossip)
@@ -100,7 +100,7 @@ enum Commands {
/// Register and resolve names on Base L2
Name(name::NameArgs),
/// Check your gitlawb installation and connectivity
/// Check your Twigpine installation and connectivity
Doctor(doctor::DoctorArgs),
/// Zero-to-push in one command — init repo, identity, register, remote
+91 -15
View File
@@ -2,7 +2,7 @@
//!
//! Runs a JSON-RPC 2.0 server over stdin/stdout using LSP-style
//! `Content-Length` framing. Exposes 15 tools that give LLM agents
//! structured access to the gitlawb network.
//! structured access to the Twigpine network.
//!
//! # Tools
//! identity_show — current agent DID
@@ -56,7 +56,11 @@ pub enum McpCmd {
/// Start the MCP server (stdin/stdout JSON-RPC)
Serve {
/// Node URL to connect to
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory
#[arg(long)]
@@ -181,12 +185,12 @@ fn tool_definitions() -> Value {
},
{
"name": "node_info",
"description": "Get metadata about the connected gitlawb node: DID, version, network, protocols.",
"description": "Get metadata about the connected Twigpine node: DID, version, network, protocols.",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "node_health",
"description": "Check if the gitlawb node is alive and responding.",
"description": "Check if the Twigpine node is alive and responding.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -204,12 +208,12 @@ fn tool_definitions() -> Value {
},
{
"name": "repo_list",
"description": "List all repositories on the connected gitlawb node.",
"description": "List all repositories on the connected Twigpine node.",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "repo_list_federated",
"description": "List repositories across ALL nodes in the gitlawb network (federation). Returns repos from this node and all known peer nodes, each annotated with node_url and node_did.",
"description": "List repositories across ALL nodes in the Twigpine network (federation). Returns repos from this node and all known peer nodes, each annotated with node_url and node_did.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -262,7 +266,7 @@ fn tool_definitions() -> Value {
},
{
"name": "agent_register",
"description": "Register this agent with the gitlawb node to get a bootstrap UCAN token.",
"description": "Register this agent with the Twigpine node to get a bootstrap UCAN token.",
"inputSchema": {
"type": "object",
"properties": {
@@ -278,7 +282,7 @@ fn tool_definitions() -> Value {
},
{
"name": "agent_capabilities",
"description": "List all available UCAN capability strings in the gitlawb protocol.",
"description": "List all available UCAN capability strings in the Twigpine protocol.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -775,13 +779,15 @@ async fn call_tool(
let did: gitlawb_core::did::Did = did_str
.parse()
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
if let Ok(vk) = did.to_verifying_key() {
let doc = gitlawb_core::did::DidDocument::new(did, &vk);
Ok(serde_json::to_string_pretty(&doc)?)
} else {
Err(anyhow::anyhow!(
"cannot resolve '{did_str}' locally — only did:key is supported without a resolver"
))
let is_did_key = did.is_did_key();
match did.to_verifying_key() {
Ok(vk) => {
let doc = gitlawb_core::did::DidDocument::new(did, &vk);
Ok(serde_json::to_string_pretty(&doc)?)
}
Err(e) => Err(anyhow::anyhow!(did_resolve_failure_message(
did_str, is_did_key, &e
))),
}
}
@@ -1341,6 +1347,76 @@ fn write_message(writer: &mut impl Write, value: &Value) -> Result<()> {
Ok(())
}
/// Message for a `did_resolve` failure. A `did:key` that fails to resolve is a
/// different situation from a DID method we have no resolver for, so saying
/// "only did:key is supported" about something that IS a did:key sends the
/// reader looking for the wrong problem.
fn did_resolve_failure_message(
did_str: &str,
is_did_key: bool,
err: &gitlawb_core::Error,
) -> String {
if is_did_key {
format!("cannot resolve '{did_str}': {err}")
} else {
format!("cannot resolve '{did_str}' locally — only did:key is supported without a resolver")
}
}
#[cfg(test)]
mod did_resolve_message_tests {
use super::did_resolve_failure_message;
use gitlawb_core::did::Did;
use std::str::FromStr;
/// A well-formed did:key encoding the compressed identity point, which is
/// small-order. Derived from the point bytes so the fixture states WHY it
/// is weak rather than pinning an opaque string (the literal also trips
/// secret scanners, which cannot tell a public DID from an API key).
fn weak_did_key() -> String {
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).expect("decompresses");
Did::from_verifying_key(&vk).to_string()
}
#[test]
fn test_did_key_failure_does_not_claim_did_key_is_unsupported() {
// Drive the real parse and the real resolution error rather than
// hardcoding the discriminator, so flipping it at the call site fails.
let did_str = &weak_did_key();
let did = Did::from_str(did_str).expect("well-formed did:key");
let err = did
.to_verifying_key()
.expect_err("a small-order did:key must not resolve");
let msg = did_resolve_failure_message(did_str, did.is_did_key(), &err);
assert!(
!msg.contains("only did:key is supported"),
"a did:key must not be told did:key is unsupported: {msg}"
);
assert!(
msg.contains("small-order"),
"the real cause must survive into the message: {msg}"
);
}
#[test]
fn test_non_key_method_keeps_the_no_resolver_message() {
let did_str = "did:web:example.com";
let did = Did::from_str(did_str).expect("did:web parses");
let err = did
.to_verifying_key()
.expect_err("did:web has no local resolver");
let msg = did_resolve_failure_message(did_str, did.is_did_key(), &err);
assert!(
msg.contains("only did:key is supported"),
"an unresolvable method keeps its existing explanation: {msg}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
+14 -10
View File
@@ -3,7 +3,7 @@
//! Flow:
//! 1. Parse source URL → derive repo name (or use --repo override)
//! 2. `git clone --mirror <source>` into a temp dir
//! 3. Create the repo on the gitlawb node (authenticated)
//! 3. Create the repo on the Twigpine node (authenticated)
//! 4. `git push --mirror gitlawb://<did>/<name>`
//! 5. Clean up temp dir
@@ -22,15 +22,19 @@ pub struct MirrorArgs {
/// Source repository URL (https://github.com/owner/repo, GitLab, or any public git URL)
pub source: String,
/// Name to use on gitlawb (default: last path segment of source URL, .git suffix stripped)
/// Name to use on Twigpine (default: last path segment of source URL, .git suffix stripped)
#[arg(long)]
pub repo: Option<String>,
/// Optional description for the gitlawb repo
/// Optional description for the Twigpine repo
#[arg(long, short)]
pub description: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
pub node: String,
#[arg(long)]
@@ -62,7 +66,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
let owner_did = kp.did().to_string();
println!("Mirroring {source}");
println!(" → gitlawb repo: {name}");
println!(" → Twigpine repo: {name}");
println!();
// ── 3. git clone --mirror into a temp dir ─────────────────────────────
@@ -89,8 +93,8 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
bail!("git clone --mirror failed\nCheck that the source URL is accessible: {source}");
}
// ── 4. Create the repo on gitlawb ─────────────────────────────────────
println!("Creating repo on gitlawb node...");
// ── 4. Create the repo on Twigpine ─────────────────────────────────────
println!("Creating repo on Twigpine node...");
let client = NodeClient::new(&args.node, Some(kp));
let body = serde_json::to_vec(&json!({
@@ -113,7 +117,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
bail!("failed to create repo ({status}): {msg}");
}
// ── 5. git push --mirror to gitlawb ───────────────────────────────────
// ── 5. git push --mirror to Twigpine ───────────────────────────────────
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
println!("Pushing to {gitlawb_url}...");
@@ -125,7 +129,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
.context("failed to run git push")?;
if !push_status.success() {
bail!("git push --mirror failed\nThe repo was created on gitlawb but the push did not complete.\nYou can retry with:\n cd {path} && git push --mirror {gitlawb_url}",
bail!("git push --mirror failed\nThe repo was created on Twigpine but the push did not complete.\nYou can retry with:\n cd {path} && git push --mirror {gitlawb_url}",
path = mirror_path.display());
}
@@ -134,7 +138,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
println!();
println!("✓ Mirror complete: {name}");
println!(" Clone: git clone {gitlawb_url}");
println!(" View: https://gitlawb.com/{owner_short}/{name}");
println!(" View: https://twigpine.com/{owner_short}/{name}");
Ok(())
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub enum NameCmd {
/// Name registry contract address
#[arg(long, env = "GITLAWB_CONTRACT_NAME_REGISTRY", default_value = DEFAULT_NAME_REGISTRY)]
contract: String,
/// gitlawb identity directory (reads your DID)
/// Twigpine identity directory (reads your DID)
#[arg(long)]
dir: Option<PathBuf>,
},
+16 -4
View File
@@ -20,7 +20,11 @@ pub struct NodeArgs {
pub enum NodeCmd {
/// Show a comprehensive status dashboard for the node
Status {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
@@ -29,13 +33,21 @@ pub enum NodeCmd {
/// Check trust score for a DID
Trust {
did: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
/// Resolve a DID to node info
Resolve {
did: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
@@ -312,7 +324,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
// ── Render dashboard ──────────────────────────────────────────────────
println!("╔══════════════════════════════════════════════╗");
println!("║ gitlawb node status ║");
println!("║ Twigpine node status ║");
println!("╚══════════════════════════════════════════════╝");
println!();
+1 -1
View File
@@ -72,7 +72,7 @@ pub async fn cmd_register(
let token_addr: Address = token.parse().context("invalid token address")?;
let contract_addr: Address = contract.parse().context("invalid contract address")?;
println!("Registering gitlawb node on Base L2...");
println!("Registering Twigpine node on Base L2...");
println!(" DID: {did}");
println!(" Stake: {stake} $GITLAWB");
println!(" HTTP URL: {http_url}");
+20 -4
View File
@@ -20,14 +20,22 @@ pub struct PeerArgs {
pub enum PeerCmd {
/// List known peers on the node
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
/// Announce yourself to a peer node (adds you to their peer list)
Add {
/// The URL of the peer node to announce to
peer_url: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -36,14 +44,22 @@ pub enum PeerCmd {
Ping {
/// The DID of the peer to ping
did: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
/// Resolve a DID to its node URL and p2p info (checks local cache then Kademlia DHT)
Resolve {
/// The DID to resolve (e.g. did:key:z6Mk...)
did: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
}
+40 -8
View File
@@ -35,7 +35,11 @@ pub enum PrCmd {
/// Repo owner DID short key (defaults to your DID — use when opening a PR on another user's repo)
#[arg(long)]
owner: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -44,7 +48,11 @@ pub enum PrCmd {
List {
/// Repository name
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -55,7 +63,11 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -66,7 +78,11 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -77,7 +93,11 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -94,7 +114,11 @@ pub enum PrCmd {
/// Review body
#[arg(long, short)]
body: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -108,7 +132,11 @@ pub enum PrCmd {
/// Comment body
#[arg(long, short)]
body: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -119,7 +147,11 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+26 -6
View File
@@ -1,6 +1,6 @@
//! `gl profile` — manage your agent profile (display name, bio, avatar, socials).
//!
//! Profile metadata is stored on the gitlawb node and optionally pinned to IPFS
//! Profile metadata is stored on the Twigpine node and optionally pinned to IPFS
//! for decentralized resolution. All writes are signed with your DID key.
use anyhow::{Context, Result};
@@ -59,7 +59,11 @@ pub enum ProfileCmd {
pin: bool,
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory
@@ -70,7 +74,11 @@ pub enum ProfileCmd {
/// Show your own profile
Show {
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory
@@ -84,7 +92,11 @@ pub enum ProfileCmd {
did: String,
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
@@ -98,7 +110,11 @@ pub enum ProfileCmd {
pin: bool,
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory
@@ -109,7 +125,11 @@ pub enum ProfileCmd {
/// Export your profile as JSON
Export {
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory
+15 -3
View File
@@ -23,7 +23,11 @@ pub enum ProtectCmd {
/// Repository name or owner/repo
#[arg(long)]
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -34,7 +38,11 @@ pub enum ProtectCmd {
branch: String,
#[arg(long)]
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -43,7 +51,11 @@ pub enum ProtectCmd {
List {
#[arg(long)]
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+1 -1
View File
@@ -15,7 +15,7 @@ use std::path::PathBuf;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
const PUBLIC_NODE: &str = "https://node.twigpine.com";
#[derive(Args)]
pub struct QuickstartArgs {
+7 -3
View File
@@ -1,4 +1,4 @@
//! `gl register` — register this agent identity with a gitlawb node.
//! `gl register` — register this agent identity with a Twigpine node.
//!
//! Sends a signed POST /api/register request and saves the returned bootstrap
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
@@ -14,7 +14,11 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct RegisterArgs {
/// Node URL to register with
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
pub node: String,
/// Capabilities to advertise (comma-separated)
@@ -100,7 +104,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
println!(" UCAN expires: {expires}");
println!();
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
println!(" You are now a verified agent on the gitlawb network.");
println!(" You are now a verified agent on the Twigpine network.");
Ok(())
}
+66 -14
View File
@@ -25,14 +25,22 @@ pub enum RepoCmd {
private: bool,
#[arg(long, default_value = "main")]
branch: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// List repositories on a node
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -40,7 +48,11 @@ pub enum RepoCmd {
/// Print the gitlawb:// clone command for a repo
Clone {
name: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -48,7 +60,11 @@ pub enum RepoCmd {
/// Show repository metadata
Info {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -63,7 +79,11 @@ pub enum RepoCmd {
/// Max number of commits to show
#[arg(long, short, default_value = "20")]
limit: u32,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -75,7 +95,11 @@ pub enum RepoCmd {
/// New name for the fork (defaults to source repo name)
#[arg(long, short)]
name: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -84,7 +108,11 @@ pub enum RepoCmd {
LabelAdd {
repo: String,
label: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -93,7 +121,11 @@ pub enum RepoCmd {
LabelRemove {
repo: String,
label: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -101,7 +133,11 @@ pub enum RepoCmd {
/// List labels on a repository
LabelList {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -110,7 +146,11 @@ pub enum RepoCmd {
Owner {
/// Repository name or owner/repo
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -126,7 +166,11 @@ pub enum RepoCmd {
#[arg(long)]
url: String,
/// URL of the origin node (where the repo lives)
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -135,7 +179,11 @@ pub enum RepoCmd {
ReplicaUnregister {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -144,7 +192,11 @@ pub enum RepoCmd {
Replicas {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -265,7 +317,7 @@ async fn cmd_create(
println!("✓ Created repository: {name}");
println!(" Clone: git clone {gitlawb_url}");
println!(" HTTP: {clone_url}");
println!(" View: https://gitlawb.com/{owner_short}/{name}");
println!(" View: https://twigpine.com/{owner_short}/{name}");
if let Some(desc) = payload["description"].as_str().filter(|s| !s.is_empty()) {
println!(" Desc: {desc}");
}
+15 -3
View File
@@ -20,7 +20,11 @@ pub enum StarCmd {
Add {
/// Repository name (owner/repo or just name — owner derived from identity)
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -29,7 +33,11 @@ pub enum StarCmd {
Remove {
/// Repository name (owner/repo or just name — owner derived from identity)
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -38,7 +46,11 @@ pub enum StarCmd {
Count {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+7 -3
View File
@@ -10,7 +10,11 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct StatusArgs {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
pub node: String,
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -51,7 +55,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
}
}
// ── Current git repo + gitlawb remote ─────────────────────────────────
// ── Current git repo + Twigpine remote ─────────────────────────────────
let remote = detect_gitlawb_remote();
match &remote {
Some((did, repo)) => {
@@ -59,7 +63,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
println!(" repo {short_did}/{repo}");
}
None => {
println!(" repo (not in a gitlawb repo — no gitlawb:// remote)");
println!(" repo (not in a Twigpine repo — no gitlawb:// remote)");
}
}
+5 -1
View File
@@ -11,7 +11,11 @@ pub struct SyncArgs {
pub cmd: SyncCmd,
/// Node URL
#[arg(long, env = "GITLAWB_NODE", default_value = "https://node.gitlawb.com")]
#[arg(
long,
env = "GITLAWB_NODE",
default_value = "https://node.twigpine.com"
)]
pub node: String,
/// Identity directory for signed sync trigger requests
+30 -6
View File
@@ -38,7 +38,11 @@ pub enum TaskCmd {
/// ISO-8601 deadline for the task
#[arg(long)]
deadline: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -51,19 +55,31 @@ pub enum TaskCmd {
assignee_did: Option<String>,
#[arg(long, default_value = "50")]
limit: i64,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
/// View a specific task
View {
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
},
/// Claim a pending task
Claim {
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -73,7 +89,11 @@ pub enum TaskCmd {
id: String,
#[arg(long)]
result: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -83,7 +103,11 @@ pub enum TaskCmd {
id: String,
#[arg(long)]
reason: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+15 -3
View File
@@ -28,7 +28,11 @@ pub enum VisibilityCmd {
/// Replication mode: "a" (hide, whole-repo only) or "b" (lock content)
#[arg(long, default_value = "b")]
mode: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -38,7 +42,11 @@ pub enum VisibilityCmd {
path_glob: String,
#[arg(long)]
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -47,7 +55,11 @@ pub enum VisibilityCmd {
List {
#[arg(long)]
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+15 -3
View File
@@ -31,7 +31,11 @@ pub enum WebhookCmd {
/// Optional HMAC secret for payload signing (sets X-Gitlawb-Signature-256)
#[arg(long)]
secret: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -40,7 +44,11 @@ pub enum WebhookCmd {
List {
/// Repository name
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -51,7 +59,11 @@ pub enum WebhookCmd {
repo: String,
/// Webhook ID
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+1 -1
View File
@@ -60,7 +60,7 @@ pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write)
}
Ok(resp) => {
let status = resp.status();
let raw = read_body_capped(resp, 8 * 1024).await;
let raw = read_body_capped(resp, 8 * 1024).await.text;
let msg = serde_json::from_str::<Value>(&raw)
.ok()
.and_then(|v| {
+1 -1
View File
@@ -1,4 +1,4 @@
# Run a gitlawb node + Postgres locally. For on-chain PoS operation, supply
# Run a Twigpine node + Postgres locally. For on-chain PoS operation, supply
# the operator key + contract addresses via a .env file or shell environment.
services:
+1 -1
View File
@@ -1,4 +1,4 @@
# gitlawb economics
# Twigpine economics
How $GITLAWB flows through the protocol and how stakers earn.
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer roadmap
Gitlawb node is live software. People are already using the network, so maintainer work should optimize for uptime, compatibility, and predictable rollouts before feature velocity.
Twigpine node is live software. People are already using the network, so maintainer work should optimize for uptime, compatibility, and predictable rollouts before feature velocity.
Mission: when code is pushed to a node, the node should not go down, and the network should be able to serve git content like a resilient delivery layer.
+4 -4
View File
@@ -53,8 +53,8 @@ Recommended next CI additions:
Fixed in this pass:
- `install.sh` now downloads from `Gitlawb/node`, matches release asset names (`gitlawb-node-<version>-<target>.tar.gz`), handles `--version vX.Y.Z`, extracts the packaged directory, verifies checksums, and installs `gl`, `git-remote-gitlawb`, and `gitlawb-node` when present.
- `docs/RUN-A-NODE.md` now matches the README Rust requirement (`1.91+`) and the release workflow's GHCR image path (`ghcr.io/gitlawb/node:latest`).
- `install.sh` now downloads from `Twigpine/node`, matches release asset names (`gitlawb-node-<version>-<target>.tar.gz`), handles `--version vX.Y.Z`, extracts the packaged directory, verifies checksums, and installs `gl`, `git-remote-gitlawb`, and `gitlawb-node` when present.
- `docs/RUN-A-NODE.md` now matches the README Rust requirement (`1.91+`) and the release workflow's GHCR image path (`ghcr.io/twigpine/node:latest`).
- `docs/ECONOMICS.md` no longer claims this repo ships a `keeper-distribute.yml` workflow that is not present.
- `.env.example` now distinguishes HTTP bootstrap peers from libp2p multiaddrs and documents seed-list opt-out.
- `scripts/build-bins.sh` now writes to `dist/bin` instead of a missing `web/public/bin` path.
@@ -62,8 +62,8 @@ Fixed in this pass:
Remaining doc caveats:
- Public docs URLs (`gitlawb.com/install.sh`, `docs.gitlawb.com`) were not verified in this local pass.
- `git-remote-gitlawb` defaults to `http://127.0.0.1:7545`, while most `gl` commands default to `https://node.gitlawb.com`; the docs should be explicit about setting `GITLAWB_NODE` for public-network cloning/fetching.
- Public docs URLs (`twigpine.com/install.sh`, `docs.gitlawb.com`) were not verified in this local pass.
- `git-remote-gitlawb` defaults to `http://127.0.0.1:7545`, while most `gl` commands default to `https://node.twigpine.com`; the docs should be explicit about setting `GITLAWB_NODE` for public-network cloning/fetching.
## Docker and self-hosting readiness
+4 -4
View File
@@ -1,4 +1,4 @@
# Running a gitlawb node
# Running a Twigpine node
Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earning protocol fees as a PoS operator.
@@ -15,12 +15,12 @@ Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earn
## 1. Install the CLI
```bash
curl -fsSL https://gitlawb.com/install.sh | sh
curl -fsSL https://twigpine.com/install.sh | sh
# or build from source:
cargo install --path crates/gl
```
## 2. Create a gitlawb identity
## 2. Create a Twigpine identity
Every node is identified by an Ed25519 DID keypair:
@@ -69,7 +69,7 @@ docker run -d \
-e GITLAWB_CONTRACT_NODE_STAKING=$GITLAWB_CONTRACT_NODE_STAKING \
-e GITLAWB_CHAIN_RPC_URL=$GITLAWB_CHAIN_RPC_URL \
-e GITLAWB_OPERATOR_STRICT_MODE=true \
ghcr.io/gitlawb/node:latest
ghcr.io/twigpine/node:latest
```
### Option B — docker-compose
+1 -1
View File
@@ -24,7 +24,7 @@ file, so it points to `../../Dockerfile`.
## Deploying to AWS
See [`aws/README.md`](aws/README.md) — Terraform for a single EC2 instance
running the published `ghcr.io/gitlawb/node` image with Docker compose.
running the published `ghcr.io/twigpine/node` image with Docker compose.
## What intentionally stays at the repo root
+2 -2
View File
@@ -1,4 +1,4 @@
# gitlawb node on AWS (Terraform)
# Twigpine node on AWS (Terraform)
Single EC2 instance running the published node image + Postgres via Docker
compose, with a persistent encrypted EBS volume, Elastic IP, SSM access, and
@@ -7,7 +7,7 @@ daily snapshots.
```text
Elastic IP ──► EC2 t4g.small (Amazon Linux 2023, arm64)
7545/tcp docker compose:
7546/udp ├─ node (ghcr.io/gitlawb/node, pulled — not built)
7546/udp ├─ node (ghcr.io/twigpine/node, pulled — not built)
└─ postgres:16-alpine
EBS gp3 volume mounted at /mnt/data
├─ node/ → container /data (repos + identity key)
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more