Compare commits

..
Author SHA1 Message Date
beardthelion 3a82ccb8a1 fix(node): satisfy clippy on stale-local download test path join 2026-08-30 16:31:25 -05:00
beardthelion c306933e07 style(node): rustfmt advisory-lock gap-closure commits 2026-08-30 16:26:21 -05:00
beardthelion 22da2d6b96 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.
2026-08-30 16:14:25 -05:00
beardthelion 5cd6703aad 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.
2026-08-30 15:30:34 -05:00
beardthelion 85c6f48bc4 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.
2026-08-30 13:25:06 -05:00
beardthelion 9d02139dad 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).
2026-08-30 13:04:54 -05:00
beardthelion 487687c5fd 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.
2026-08-29 15:29:01 -05:00
beardthelion 2ad2f12860 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.
2026-08-29 12:29:36 -05:00
beardthelion 5d3a4ea462 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.
2026-08-29 12:01:06 -05:00
beardthelion 1911291533 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.
2026-08-28 22:53:17 -05:00
beardthelion 8e66e77c57 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.
2026-08-28 22:10:38 -05:00
beardthelion d1ce2e3291 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.
2026-08-28 16:02:06 -05:00
beardthelion 4eb0c4479c 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.
2026-08-28 15:17:52 -05:00
beardthelion 4f1c1bbd09 fix(node): drop unused logcap helper after drain_faults seam 2026-08-28 14:45:25 -05:00
beardthelion 98fe670c11 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.
2026-08-28 13:56:42 -05:00
beardthelion 3df71c9aef 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.
2026-08-25 23:29:00 -05:00
beardthelion 7ba0fefd75 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.
2026-08-25 23:09:33 -05:00
beardthelion 3c201486cc 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.
2026-08-25 23:03:07 -05:00
beardthelion b35cb31944 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.
2026-08-25 22:30:07 -05:00
beardthelion 4cee7e0935 merge origin/main into fix/279-advisory-lock-session-affinity
Reconcile the advisory-lock series with main after #173, #330, and related
landings. Conflict resolution in repo_store, repos, issues, pulls, ipfs, and
main.
2026-08-25 22:30:00 -05:00
beardthelion 9dd71e999c 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.
2026-08-18 00:43:06 -05:00
beardthelion 9f35dca002 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
2026-08-18 00:04:50 -05:00
beardthelion 2a92a1f73a 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
2026-08-17 23:23:32 -05:00
beardthelion 3d803ff217 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.
2026-08-17 23:00:06 -05:00
beardthelion 317841ee24 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.
2026-08-17 23:00:06 -05:00
beardthelion 3eabf794a5 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.
2026-08-17 22:59:52 -05:00
beardthelion 31f90c4e05 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.
2026-08-17 22:59:52 -05:00
beardthelion 60d70c7fc7 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.
2026-08-17 22:59:52 -05:00
beardthelion 9f313b9bf7 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.
2026-08-17 22:59:52 -05:00
beardthelion c4dbd8a797 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.
2026-08-17 22:59:52 -05:00
beardthelion f81ebbe614 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.
2026-08-17 22:59:52 -05:00
beardthelion 5fdb902096 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.
2026-08-17 22:59:52 -05:00
beardthelion 999ee72f3c 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.
2026-08-17 22:59:52 -05:00
beardthelion 259d5873ec 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.
2026-08-17 22:59:52 -05:00
beardthelion 10230ac50e 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.
2026-08-17 22:59:52 -05:00
beardthelion 63a9ef8b1b 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.
2026-08-17 22:58:55 -05:00
beardthelion e5558fbb27 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.
2026-08-17 22:58:26 -05:00
beardthelion af5948b204 style(node): rustfmt the new contention test
Whitespace only; cargo fmt --check gates the push.
2026-08-17 22:58:26 -05:00
beardthelion 8cf6b7ae46 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.
2026-08-17 22:58:26 -05:00
beardthelion 82143ad692 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.
2026-08-17 22:58:26 -05:00
beardthelion d346bde532 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
2026-08-17 22:58:14 -05:00
beardthelion 5d254fb336 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
2026-08-17 22:58:14 -05:00
beardthelion 9da0939312 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
2026-08-17 22:57:57 -05:00
beardthelion 9321d3b993 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
2026-08-17 22:57:57 -05:00
beardthelion 98f2daf804 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
2026-08-17 22:57:43 -05:00
beardthelion c49d3cdb57 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
2026-08-17 22:57:43 -05:00
beardthelion 5ce3c7f413 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
2026-08-17 22:57:43 -05:00
beardthelion edcf0f2ccf 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
2026-08-17 22:57:07 -05:00
beardthelion 7af306701d 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
2026-08-17 22:56:50 -05:00
111 changed files with 1493 additions and 24805 deletions

No files matched your search

+15 -19
View File
@@ -8,9 +8,24 @@
# 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
@@ -20,23 +35,4 @@ 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)
]
+4 -37
View File
@@ -1,41 +1,12 @@
# Twigpine node — environment variables
# gitlawb 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. 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.
# Path to the node's Ed25519 keypair PEM file.
# 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
@@ -132,12 +103,8 @@ GITLAWB_P2P_PORT=7546
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
GITLAWB_BOOTSTRAP_PEERS=
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
# 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.
# Require signatures on peer announce/sync write routes. Keep false during
# rolling upgrades so existing live nodes can still communicate.
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 Twigpine crate or node behavior
description: Report a defect in a Gitlawb 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/Twigpine/node/blob/main/SECURITY.md
url: https://github.com/Gitlawb/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/Twigpine/node/discussions
url: https://github.com/Gitlawb/node/discussions
about: For usage questions and open-ended discussion.
+1 -1
View File
@@ -1,5 +1,5 @@
<!--
Thanks for contributing to Twigpine. Keep PRs focused on one change.
Thanks for contributing to Gitlawb. 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/Twigpine/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
"See [CONTRIBUTING.md](https://github.com/Gitlawb/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 == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
if: ${{ github.repository == 'Gitlawb/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 == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
if: ${{ !cancelled() && github.repository == 'Gitlawb/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: Twigpine/homebrew-tap
repository: Gitlawb/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 "Twigpine CLI — decentralized git for AI agents and developers"
homepage "https://twigpine.com"
desc "Gitlawb CLI — decentralized git for AI agents and developers"
homepage "https://gitlawb.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/Twigpine/homebrew-tap.git" HEAD:main
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Gitlawb/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: Twigpine/web
repository: Gitlawb/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/Twigpine/node/releases#g' web/public/skill.md
sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/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 Twigpine ${TAG} (install scripts + version)"
git commit -m "chore: sync gitlawb ${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/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." \
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." \
|| {
# 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 Twigpine/web --head "$branch" --state open --json number --jq 'length')" || exit 1
pr_count="$(gh pr list --repo Gitlawb/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 Twigpine
# Contributing to gitlawb
Thanks for your interest in contributing. Twigpine is an open protocol — agents and humans welcome.
Thanks for your interest in contributing. gitlawb is an open protocol — agents and humans welcome.
## Quick start for contributors
```sh
# Clone and build
git clone https://github.com/Twigpine/node
git clone https://github.com/Gitlawb/node
cd node
# Build everything
@@ -38,7 +38,7 @@ docs/ Operator guides
scripts/ Build helpers
```
Smart contracts live in a separate repo: [github.com/Twigpine/contracts](https://github.com/Twigpine/contracts).
Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://github.com/Gitlawb/contracts).
## Ways to contribute
Generated
+544 -924
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 = ["Twigpine contributors"]
repository = "https://github.com/Twigpine/node"
authors = ["gitlawb contributors"]
repository = "https://github.com/gitlawb/node"
[workspace.dependencies]
# async
-1
View File
@@ -75,7 +75,6 @@ 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 Twigpine contributors
Copyright 2026 gitlawb 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 Twigpine contributors
Copyright (c) 2026 gitlawb 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
+20 -49
View File
@@ -1,10 +1,10 @@
# Twigpine Node
# Gitlawb Node
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
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 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 is not trying to be only "another git host." The long-term direction is:
Gitlawb 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 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. |
| `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. |
| `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 Twigpine Nodes?
## Why Gitlawb Nodes?
Most git hosting today depends on a small number of centralized platforms. Twigpine Nodes are designed for a different model:
Most git hosting today depends on a small number of centralized platforms. Gitlawb 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. Twigp
## Current status
Twigpine Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
Gitlawb 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/Twigpine/node.git
git clone https://github.com/Gitlawb/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://twigpine.com/install.sh | sh
curl -fsSL https://gitlawb.com/install.sh | sh
# PowerShell (Windows)
irm https://twigpine.com/install.ps1 | iex
irm https://gitlawb.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 Twigpine repo" --node http://localhost:7545
gl repo create my-repo --description "My first Gitlawb 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.twigpine.com`) require two things on writes:
Public nodes (e.g. `node.gitlawb.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.twigpine.com`) require two things on writes:
```bash
gl identity new # create did:key identity
gl register --node https://node.twigpine.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.twigpine.com # signed + auto-solves iCaptcha
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.gitlawb.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,12 +369,6 @@ 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
@@ -397,9 +391,8 @@ 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 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_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `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. |
@@ -427,33 +420,11 @@ 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
Twigpine Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
Gitlawb 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:
@@ -523,8 +494,8 @@ Build:
Output:
```txt
dist/Twigpine Node.app
dist/GitlawbNode-macOS.dmg
dist/Gitlawb Node.app
dist/Gitlawb Node.dmg
```
Features:
@@ -538,7 +509,7 @@ Features:
Unsigned local build:
```bash
xattr -cr "dist/Twigpine Node.app"
xattr -cr "dist/Gitlawb Node.app"
```
---
+4 -4
View File
@@ -2,9 +2,9 @@
## Reporting a Vulnerability
If you discover a security vulnerability in Twigpine, please **do not open a public issue**.
If you discover a security vulnerability in gitlawb, please **do not open a public issue**.
Report it privately by emailing **security@twigpine.com** with:
Report it privately by emailing **security@gitlawb.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
Twigpine is designed to be secure against:
gitlawb 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
Twigpine is **not yet** designed to defend against:
gitlawb 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 Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-06-30",
"peers": [
{
"name": "gitlawb",
"operator": "Twigpine",
"operator": "Gitlawb",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
@@ -13,7 +13,7 @@
},
{
"name": "gitlawb-node2",
"operator": "Twigpine",
"operator": "Gitlawb",
"did": null,
"http_url": "https://node2.gitlawb.com",
"p2p_multiaddr": null,
@@ -21,7 +21,7 @@
},
{
"name": "gitlawb-node3",
"operator": "Twigpine",
"operator": "Gitlawb",
"did": null,
"http_url": "https://node3.gitlawb.com",
"p2p_multiaddr": null,
@@ -29,7 +29,7 @@
},
{
"name": "gitlawb-manila",
"operator": "Twigpine",
"operator": "Gitlawb",
"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 Twigpine ref-update certs"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
+1 -1
View File
@@ -1,4 +1,4 @@
//! Pluggable provenance attachments for Twigpine ref-update certs.
//! Pluggable provenance attachments for gitlawb 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
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
+3 -3
View File
@@ -1,11 +1,11 @@
//! Decentralized Identifier (DID) types for the Twigpine network.
//! Decentralized Identifier (DID) types for the gitlawb 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 Twigpine actor is `did:key` during bootstrap
//! The canonical DID for a gitlawb actor is `did:key` during bootstrap
//! and migrates to `did:gitlawb` once DHT anchoring is live.
use ed25519_dalek::VerifyingKey;
@@ -152,7 +152,7 @@ impl From<Did> for String {
}
}
/// A DID Document for a Twigpine actor.
/// A DID Document for a gitlawb actor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidDocument {
#[serde(rename = "@context")]
+12 -235
View File
@@ -1,6 +1,6 @@
//! HTTP Signatures (RFC 9421) for gitlawb.
//!
//! Every write request to a Twigpine node must be signed by the actor's
//! Every write request to a gitlawb 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, HashSet};
use std::collections::HashMap;
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// The component identifiers covered by every Twigpine signature.
/// The component identifiers covered by every gitlawb signature.
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
/// The three headers produced by RFC 9421 signing.
@@ -67,20 +67,11 @@ impl HttpSignature {
let components_str = &rest[open + 1..close];
let params_str = &rest[close + 1..]; // starts with ';'
// 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);
}
// "\"@method\" \"@path\" \"content-digest\"" → ["@method", "@path", "content-digest"]
let components: Vec<String> = components_str
.split_whitespace()
.map(|s| s.trim_matches('"').to_string())
.collect();
let params = parse_params(params_str)?;
@@ -122,48 +113,15 @@ impl HttpSignature {
})
}
/// 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.
/// Reject if the `created` timestamp is more than 5 minutes from now.
pub fn check_created(&self) -> Result<()> {
const MAX_AGE_SECS: i64 = 300;
const MAX_FUTURE_SECS: i64 = 60;
let now = Utc::now().timestamp();
// 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 {
let skew = (now - self.created).abs();
if skew > 300 {
return Err(Error::HttpSignature(format!(
"clock skew too large: created {age}s in the past (max {MAX_AGE_SECS}s)"
"clock skew too large: {skew}s (max 300s)"
)));
}
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(())
}
@@ -245,33 +203,6 @@ 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();
@@ -341,91 +272,6 @@ 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;
@@ -489,75 +335,6 @@ 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 Twigpine identity is an Ed25519 keypair. The public key encodes into a
//! A gitlawb 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 Twigpine actor.
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
#[derive(Clone)]
pub struct Keypair {
signing_key: SigningKey,
+3 -3
View File
@@ -1,4 +1,4 @@
//! Redirect policy shared by every Twigpine HTTP client that signs its requests.
//! Redirect policy shared by every gitlawb 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 Twigpine signature also covers
/// The clause pins `@path`, and only `@path`. A gitlawb 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
// Twigpine signature binds the request to, so a hop that rewrites it hands
// gitlawb 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. Twigpine uses UCANs for:
//! with optional expiry and revocation. gitlawb 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 Twigpine capability strings.
/// Well-known gitlawb capability strings.
pub mod caps {
pub const GIT_PUSH: &str = "git/push";
pub const GIT_FETCH: &str = "git/fetch";
+10 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "gitlawb-node"
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
description = "The gitlawb 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,7 +33,6 @@ 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 }
@@ -45,14 +44,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.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"
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"
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"] }
@@ -73,7 +72,7 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
-57
View File
@@ -938,63 +938,6 @@ mod lock_pool_shed_tests {
);
}
/// #285 U2, gap-driving. `close_issue` decides authorship out of a
/// `read_snapshot`, and on a node whose only copy is the live tree that
/// snapshot IS the live tree. When a write left that tree carrying refs the
/// store never confirmed, the pre-check reads authorship out of state no
/// other node can see, so it must be refused as retryable rather than
/// answered.
///
/// No backend is configured here, which is the sharpest form: nothing can
/// ever confirm the marked tree, so serving it is not a race, it is a
/// standing wrong answer.
#[sqlx::test]
async fn close_issue_refuses_a_quarantined_snapshot_before_authorization(pool: PgPool) {
let tmp = tempfile::TempDir::new().unwrap();
let owner = "did:key:zISSUECLOSEQUARANTINEBBBBBBBBBBBBBBBBBBB";
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store =
crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool.clone());
state
.db
.create_repo(&seed_repo(owner, "quar-close"))
.await
.expect("seed repo");
// A live tree with an unresolved publish beside it.
let live = state
.repo_store
.acquire(owner, "quar-close")
.await
.expect("resolve the live path");
let _ = std::fs::remove_dir_all(&live);
crate::git::store::init_bare(&live).expect("init bare repo");
crate::git::repo_store::quarantine_local_tree(
&live,
"quar-close",
Some(&crate::git::publish::PublishAttemptId::new()),
);
let refused = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(
"did:key:zISSUECLOSEQUARSTRANGER".to_string(),
)),
Path((
owner.to_string(),
"quar-close".to_string(),
"deadbeef".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(None),
)
.await;
assert!(
matches!(refused, Err(AppError::RepoUnavailable)),
"close_issue must not read authorship out of a quarantined tree the store cannot confirm"
);
}
#[sqlx::test]
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
-19
View File
@@ -92,25 +92,6 @@ 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;
+8 -60
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 trailing dots (FQDN root): `localhost.` resolves the same as
// Drop a single trailing dot (FQDN root): `localhost.` resolves the same as
// `localhost`, so normalize before the suffix/equality checks below.
// 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 let Some(stripped) = host.strip_suffix('.') {
host = stripped.to_string();
}
if host.is_empty()
|| host == "localhost"
|| host.ends_with(".local")
@@ -167,13 +167,8 @@ pub fn is_public_http_url(raw: &str) -> bool {
}
std::net::IpAddr::V6(v6) => {
let s = v6.segments();
// 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
{
// fc00::/7 (unique-local) or fe80::/10 (link-local)
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
return false;
}
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
@@ -188,13 +183,6 @@ 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
}
@@ -532,7 +520,7 @@ mod tests {
#[test]
fn accepts_public_https_and_http() {
assert!(is_public_http_url("https://node.twigpine.com"));
assert!(is_public_http_url("https://node.gitlawb.com"));
assert!(is_public_http_url("https://manila.gitlawb.com/"));
assert!(is_public_http_url("http://203.0.113.10:7545"));
}
@@ -556,7 +544,7 @@ mod tests {
"http://[::1]:7545",
"http://gitlawb-node.internal:7545",
"http://my-node.local/",
"ftp://node.twigpine.com",
"ftp://node.gitlawb.com",
"not-a-url",
"",
// Trailing-dot FQDN of an internal host.
@@ -589,46 +577,6 @@ 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:
-11
View File
@@ -50,17 +50,6 @@ 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();
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 Twigpine DID.
//! DID resolution — look up a node by its gitlawb 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 Twigpine DID to an HTTP URL and p2p multiaddr.
/// Resolves a gitlawb 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>,
+23 -106
View File
@@ -120,7 +120,7 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
COVERED_COMPONENTS.join(", "),
missing.join(", ")
),
"hint": "See https://twigpine.com/agents#authentication",
"hint": "See https://gitlawb.com/agents#authentication",
})),
)
.into_response();
@@ -162,34 +162,17 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
.unwrap_or("/")
.to_string();
// 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
let content_digest = parts
.headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
{
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()
}
};
.unwrap_or("")
.to_string();
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.clone());
request_values.insert("content-digest".to_string(), content_digest);
// 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);
@@ -234,18 +217,23 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
.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();
// 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();
}
}
tracing::info!(did = %sig.key_id, "✓ authenticated request");
@@ -392,7 +380,7 @@ fn human_detected(message: &str) -> impl IntoResponse {
"error": "not_an_agent",
"message": message,
"hint": "gl identity new && gl register",
"docs": "https://twigpine.com/agents",
"docs": "https://gitlawb.com/agents",
})),
)
}
@@ -649,75 +637,4 @@ 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(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 Twigpine network nodes.
//! Embedded seed list of public Gitlawb 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`
+5 -642
View File
@@ -1,5 +1,5 @@
use clap::Parser;
use std::path::{Path, PathBuf};
use std::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::{Path, PathBuf};
pub const GIT_SERVICE_TIMEOUT_SECS_MAX: u64 = 100 * 365 * 24 * 60 * 60;
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "Twigpine node daemon", version)]
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
pub struct Config {
/// Directory where bare git repositories are stored
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
@@ -67,9 +67,7 @@ pub struct Config {
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
pub bootstrap_peers: Vec<String>,
/// 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.
/// Require RFC 9421 signatures on peer announce/sync write routes.
/// Keep false during rolling upgrades so existing live nodes can still gossip.
#[arg(
long,
@@ -122,18 +120,6 @@ 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 = ',')]
@@ -763,29 +749,13 @@ impl Config {
/// Resolve ~ in key_path
pub fn resolved_key_path(&self) -> PathBuf {
if self.key_path.starts_with("~/") {
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);
}
if let Some(home) = dirs_next::home_dir() {
return home.join(&self.key_path[2..]);
}
}
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
@@ -825,98 +795,6 @@ impl Config {
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(())
}
}
@@ -1657,519 +1535,4 @@ 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");
}
}
+9 -53
View File
@@ -2606,7 +2606,15 @@ 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 !self.peer_exists(did).await.unwrap_or(false) => {
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) =>
{
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
@@ -2736,21 +2744,6 @@ 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
@@ -7906,41 +7899,6 @@ 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)> {
@@ -8357,7 +8315,6 @@ 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
@@ -8455,7 +8412,6 @@ mod peers_table_writer_guard {
("prune_non_public_peers", 1),
("prune_self_peers", 1),
("seed_local_peer", 1),
("seed_peer", 1),
("upsert_peer", 2),
];
-1
View File
@@ -1,5 +1,4 @@
pub mod issues;
pub mod publish;
pub mod push_delta;
pub mod repo_store;
pub mod smart_http;
-358
View File
@@ -1,358 +0,0 @@
//! 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));
}
}
+11 -36
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-and-tree filter (`visibility_pack::replicable_objects_fail_closed`)
//! the fail-closed blob 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,9 +211,8 @@ 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) and trees (content,
/// withholdable since #135/#173) from commits/tags (structural, never withheld)
/// without typing the candidate list itself.
/// filter needs to tell blobs (content, withholdable) from commits/trees
/// (structural, never withheld) without typing the candidate list itself.
pub fn list_all_objects_with_type(
repo_path: &Path,
git_bin: &str,
@@ -245,48 +244,24 @@ 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). Production uses
/// [`all_blob_and_tree_oids`] (one typed pass); this wrapper stays for tests.
#[cfg(test)]
/// allowed set, so it never replicates (#99).
pub fn all_blob_oids(
repo_path: &Path,
git_bin: &str,
deadline: Instant,
) -> Result<HashSet<String>> {
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))
Ok(list_all_objects_with_type(repo_path, git_bin, deadline)?
.into_iter()
.filter(|(_, ty)| ty == "blob")
.map(|(oid, _)| oid)
.collect())
}
/// 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-and-tree filter, because the reachable-only withheld set
/// cannot classify dangling blobs or trees (#99, #172).
/// apply the fail-closed blob filter, because the reachable-only withheld set
/// cannot classify dangling blobs (#99).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinCandidateSet {
pub candidates: Vec<String>,
File diff suppressed because it is too large. Load diff
+20 -562
View File
@@ -746,149 +746,13 @@ 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}...{source}")])
.args(["diff", &format!("{target_branch}...{source_branch}")])
.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())
}
@@ -901,11 +765,13 @@ 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}...{source}")])
.args([
"diff",
"--name-only",
"-z",
&format!("{target_branch}...{source_branch}"),
])
.current_dir(repo_path)
.output()
.context("failed to run git diff --name-only")?;
@@ -933,78 +799,18 @@ 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");
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.
// Clean up any leftover worktree
if worktree_path.exists() {
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
"--force",
"_merge_worktree",
])
.args(["worktree", "remove", "--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");
}
// (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.
// Create worktree on target branch
let wt = Command::new("git")
.args(["worktree", "add", "_merge_worktree", target_branch])
.current_dir(repo_path)
@@ -1017,31 +823,12 @@ 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_ref,
source_branch,
"-m",
&format!(
"Merge branch '{}' into {} ({})",
@@ -1059,7 +846,11 @@ pub fn merge_branch(
let success = merge.status.success();
// Always remove worktree
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);
if !success {
bail!(
@@ -1068,24 +859,12 @@ pub fn merge_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.
// Get new HEAD of target branch
let head = Command::new("git")
.args(["rev-parse", "--verify", &format!("{target_ref}^{{commit}}")])
.args(["rev-parse", &format!("refs/heads/{target_branch}")])
.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())
}
@@ -1100,73 +879,7 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
#[cfg(test)]
mod tests {
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 super::branch_diff_names;
use std::path::Path;
use std::process::Command;
@@ -2313,259 +2026,4 @@ 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");
}
}
+90 -374
View File
@@ -10,64 +10,34 @@ use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context, Result};
use aws_sdk_s3::Client as S3Client;
use tracing::{debug, info, warn};
use tracing::{debug, info};
// 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 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,
}
/// 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,
}
/// Why an upload failed, split so a caller can tell "someone else already
/// published this key" (expected, and dropping our bytes is the correct
/// outcome) from a real storage failure.
#[derive(Debug, thiserror::Error)]
pub enum UploadError {
#[error("upload precondition lost (HTTP {status})")]
PreconditionLost { status: u16 },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
/// Wrapper around the S3 client with the configured bucket.
@@ -75,51 +45,6 @@ fn classify_put_failure<E, R>(
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 {
@@ -132,8 +57,6 @@ impl TigrisClient {
Ok(Self {
s3,
bucket: bucket.to_string(),
#[cfg(test)]
compress_gate: None,
})
}
@@ -161,19 +84,9 @@ impl TigrisClient {
Self {
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")
@@ -209,24 +122,6 @@ impl TigrisClient {
/// 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))
}
/// 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
@@ -236,20 +131,11 @@ impl TigrisClient {
.send()
.await
{
Ok(out) => {
let etag = out
.e_tag()
Ok(out) => Ok(Some(
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,
}))
}
.to_string(),
)),
Err(e) => {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
Ok(None)
@@ -260,110 +146,26 @@ impl TigrisClient {
}
}
/// 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.
/// fenced by `precondition`.
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
}
/// 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> {
) -> std::result::Result<(), UploadError> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), attempt = %attempt, "uploading repo to tigris");
debug!(key = %key, path = %local_path.display(), "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 = {
// Create tar.zst in memory
let archive_bytes = tokio::task::spawn_blocking({
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"),
));
}
};
move || compress_repo(&local_path)
})
.await
.context("tar task panicked")?
.context("compressing repo")?;
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
@@ -372,12 +174,6 @@ impl TigrisClient {
.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");
match &precondition {
@@ -386,140 +182,47 @@ impl TigrisClient {
UploadPrecondition::Unconditional => {}
}
// 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,
}
}
if let Err(e) = req.send().await {
// `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 (`delete` has no callers), 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 {
return Err(UploadError::PreconditionLost {
status: status.expect("a lost precondition came from a status"),
});
}
};
return Err(UploadError::Other(
anyhow::Error::new(e).context(format!("tigris PUT {key}")),
));
}
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}"))
}
}
info!(key = %key, "uploaded repo to tigris");
Ok(())
}
/// Download a repo archive from Tigris and extract to local disk.
@@ -624,6 +327,19 @@ impl TigrisClient {
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
Ok(extracted)
}
/// Delete a repo archive from Tigris.
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(())
}
}
/// Compress a bare repo directory into a tar.zst byte vector.
+29 -359
View File
@@ -1,9 +1,7 @@
//! 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).
//! 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.
use crate::db::VisibilityRule;
use crate::visibility::{visibility_check, Decision};
@@ -542,79 +540,6 @@ 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.
@@ -663,9 +588,8 @@ 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 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.
/// passes its object list through; option B would later reroute the withheld
/// ones through encrypt-then-pin instead of dropping them.
pub fn replicable_objects(all: Vec<String>, withheld: &HashSet<String>) -> Vec<String> {
all.into_iter()
.filter(|oid| !withheld.contains(oid))
@@ -703,23 +627,6 @@ 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
@@ -920,12 +827,11 @@ 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 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`].
/// 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`.
fn tree_paths(
repo_path: &Path,
git_bin: &str,
@@ -1244,29 +1150,22 @@ pub fn reachable_commit_tag_oids_bounded(
Ok(set)
}
/// 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`].
/// 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`.
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| {
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)
})
.filter(|oid| !all_blob_oids.contains(oid) || allowed_blobs.contains(oid))
.collect()
}
@@ -2485,49 +2384,28 @@ esac\n";
}
#[test]
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();
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();
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(),
"tag1".to_string(),
"t_root".to_string(),
"t_pub".to_string(),
"t_secret".to_string(),
"t_dangling".to_string(),
"tree1".to_string(),
"b_pub".to_string(),
"b_secret".to_string(),
"b_dangling".to_string(),
];
let got = replicable_objects_fail_closed(
candidates,
&allowed_blobs,
&all_blobs,
&allowed_trees,
&all_trees,
);
let got = replicable_objects_fail_closed(candidates, &allowed, &all_blobs);
assert_eq!(
got,
vec![
"commit1".to_string(),
"tag1".to_string(),
"t_root".to_string(),
"t_pub".to_string(),
"tree1".to_string(),
"b_pub".to_string()
]
);
@@ -2604,12 +2482,8 @@ 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, &no_trees, &no_trees);
let replicable = replicable_objects_fail_closed(candidates, &allowed, &all_blobs);
assert!(
!replicable.contains(&dangling_oid),
"#99: a dangling private blob must not replicate"
@@ -2620,210 +2494,6 @@ 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
+2 -25
View File
@@ -676,20 +676,7 @@ async fn warm_candidates(
&repo.owner_did,
&repo.name,
) {
// 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(p) if p.is_dir() => 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");
@@ -1028,16 +1015,6 @@ 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;
@@ -1625,7 +1602,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 or withheld-subtree tree. `repo_path` is still needed to read each
/// function never sees a withheld blob. `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
+3 -127
View File
@@ -1,4 +1,4 @@
//! Prometheus metrics for the Twigpine node.
//! Prometheus metrics for the gitlawb node.
//!
//! Exposes a small, deliberately conservative set of counters and histograms
//! that cover the questions an operator actually asks of a live node:
@@ -11,11 +11,6 @@
//! `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
@@ -38,8 +33,8 @@
use std::sync::OnceLock;
use prometheus::{
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
Registry, TextEncoder,
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
TextEncoder,
};
/// The single, process-wide metrics registry. Initialized by [`init`].
@@ -54,15 +49,8 @@ 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
@@ -180,33 +168,6 @@ 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",
@@ -241,18 +202,6 @@ 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)");
@@ -321,66 +270,6 @@ 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() {
@@ -395,13 +284,6 @@ 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> {
@@ -483,12 +365,6 @@ 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
+1 -2
View File
@@ -70,8 +70,7 @@ 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 (blobs and
/// withheld-subtree trees already dropped): the caller
/// `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. `repo_path` is
/// still needed to read each object's bytes, and `git_bin` names the binary those
-17
View File
@@ -143,23 +143,6 @@ 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;
File diff suppressed because it is too large. Load diff
@@ -1,637 +0,0 @@
//! 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}"
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gl"
description = "The Twigpine CLI — identity management, node control, MCP server"
description = "The gitlawb CLI — identity management, node control, MCP server"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
+2 -10
View File
@@ -17,11 +17,7 @@ pub struct AgentArgs {
pub enum AgentCmd {
/// List agents registered on a node
List {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Filter by capability (e.g. git:push)
#[arg(long)]
@@ -33,11 +29,7 @@ pub enum AgentCmd {
Show {
/// Agent DID or short key segment
did: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
}
+8 -40
View File
@@ -39,11 +39,7 @@ pub enum BountyCmd {
/// Deadline in seconds (default 7 days)
#[arg(long)]
deadline: Option<i64>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -55,11 +51,7 @@ pub enum BountyCmd {
/// Filter by status: open, claimed, submitted, completed
#[arg(long)]
status: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -68,11 +60,7 @@ pub enum BountyCmd {
Show {
/// Bounty ID
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -84,11 +72,7 @@ pub enum BountyCmd {
/// Your wallet address for payout
#[arg(long)]
wallet: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -100,11 +84,7 @@ pub enum BountyCmd {
/// Pull request ID
#[arg(long)]
pr: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -116,11 +96,7 @@ pub enum BountyCmd {
/// On-chain payout tx hash
#[arg(long)]
tx_hash: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -129,22 +105,14 @@ pub enum BountyCmd {
Cancel {
/// Bounty ID
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Show bounty stats and leaderboard
Stats {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
}
+2 -10
View File
@@ -26,11 +26,7 @@ pub enum CertCmd {
List {
/// Repository in <owner>/<repo> or <repo> format
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -41,11 +37,7 @@ pub enum CertCmd {
repo: String,
/// Certificate ID
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+2 -6
View File
@@ -15,11 +15,7 @@ pub struct ChangelogArgs {
/// Maximum number of events to show
#[arg(long, default_value = "20")]
pub limit: usize,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -35,7 +31,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
};
if repo.is_empty() {
anyhow::bail!("no repo specified — pass <repo> or run from inside a Twigpine repo");
anyhow::bail!("no repo specified — pass <repo> or run from inside a gitlawb repo");
}
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
+3 -7
View File
@@ -1,4 +1,4 @@
//! `gl clone`: clean partial clone of a Twigpine repo with private subtrees.
//! `gl clone`: clean partial clone of a gitlawb 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,11 +76,7 @@ pub struct CloneArgs {
#[arg(long)]
pub branch: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
/// Arweave gateway for B3 manifest discovery/fetch when a node cannot supply
@@ -578,7 +574,7 @@ fn merge_manifests(
.collect()
}
/// Option B3 fallback recovery, with no dependency on a Twigpine node API. Query
/// Option B3 fallback recovery, with no dependency on a gitlawb 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 Twigpine installation and connectivity.
//! `gl doctor` — check your gitlawb 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.twigpine.com)
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.gitlawb.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.twigpine.com";
const PUBLIC_NODE: &str = "https://node.gitlawb.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 Twigpine setup");
println!("gl doctor — checking your gitlawb 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.twigpine.com",
"export GITLAWB_NODE=https://node.gitlawb.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://twigpine.com/install.sh | sh",
"upgrade gl: curl -sSf https://gitlawb.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://twigpine.com/install.sh | sh",
"curl -sSf https://gitlawb.com/install.sh | sh",
));
}
@@ -408,7 +408,7 @@ fn which_in_path(name: &str) -> bool {
.unwrap_or(false)
}
/// Fetch the latest release tag from Twigpine/node (the actual release repo — see install.sh's
/// Fetch the latest release tag from Gitlawb/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/Twigpine/node/releases/latest"
"{github_api_base}/repos/Gitlawb/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://twigpine.com/install.sh | sh",
"curl -sSf https://gitlawb.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.twigpine.com/localhost"));
assert!(!is_loopback_url("https://node.gitlawb.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/Twigpine/node/releases/latest")
.mock("GET", "/repos/Gitlawb/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/Twigpine/node/releases/latest")
.mock("GET", "/repos/Gitlawb/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/Twigpine/node/releases/latest")
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(403)
.create_async()
.await;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Signed HTTP client for Twigpine API calls (async).
//! Signed HTTP client for gitlawb 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 Twigpine network.");
println!(" Your DID is your identity on the gitlawb network.");
println!(" Keep your key file safe — it cannot be recovered if lost.");
Ok(())
+3 -7
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 Twigpine
//! registers with the node, creates a remote repo, adds the gitlawb
//! remote, and pushes.
use anyhow::{Context, Result};
@@ -19,11 +19,7 @@ pub struct InitArgs {
pub name: Option<String>,
/// Node URL to register with
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
@@ -155,7 +151,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
println!(" Repository created.");
}
// 5. Add Twigpine remote
// 5. Add gitlawb 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}");
+3 -11
View File
@@ -1,6 +1,6 @@
//! `gl ipfs` — IPFS pin management commands.
//!
//! Communicates with the Twigpine node to list pinned CIDs and retrieve git
//! Communicates with the gitlawb node to list pinned CIDs and retrieve git
//! objects by their content-addressed CID.
use std::path::PathBuf;
@@ -22,11 +22,7 @@ pub struct IpfsArgs {
pub enum IpfsCmd {
/// List all CIDs pinned to the node's local IPFS daemon
List {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
@@ -73,11 +69,7 @@ pub enum IpfsCmd {
Get {
/// The CIDv1 string (e.g. bafkrei...)
cid: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
+6 -30
View File
@@ -32,11 +32,7 @@ pub enum IssueCmd {
/// Issue body (optional)
#[arg(long, short)]
body: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -45,11 +41,7 @@ pub enum IssueCmd {
List {
/// Repository in <owner>/<repo> or <repo> format
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -60,11 +52,7 @@ pub enum IssueCmd {
repo: String,
/// Issue ID
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -75,11 +63,7 @@ pub enum IssueCmd {
repo: String,
/// Issue ID
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -90,11 +74,7 @@ pub enum IssueCmd {
id: String,
#[arg(long, short)]
body: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -103,11 +83,7 @@ pub enum IssueCmd {
Comments {
repo: String,
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.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 = "Twigpine CLI — identity, repos, MCP server",
about = "gitlawb CLI — identity, repos, MCP server",
version,
propagate_version = true
)]
@@ -49,16 +49,16 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Manage your Twigpine identity (DID + keypair)
/// Manage your gitlawb identity (DID + keypair)
Identity {
#[command(subcommand)]
cmd: identity::IdentityCmd,
},
/// Register this agent with a Twigpine node
/// Register this agent with a gitlawb node
Register(register::RegisterArgs),
/// Clone a Twigpine repo, handling private subtrees cleanly
/// Clone a gitlawb 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 Twigpine
/// Mirror a public GitHub/GitLab/any-git repo into gitlawb
Mirror(mirror::MirrorArgs),
/// MCP server — expose Twigpine tools to LLM agents
/// MCP server — expose gitlawb 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 Twigpine installation and connectivity
/// Check your gitlawb installation and connectivity
Doctor(doctor::DoctorArgs),
/// Zero-to-push in one command — init repo, identity, register, remote
+8 -12
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 Twigpine network.
//! structured access to the gitlawb network.
//!
//! # Tools
//! identity_show — current agent DID
@@ -56,11 +56,7 @@ pub enum McpCmd {
/// Start the MCP server (stdin/stdout JSON-RPC)
Serve {
/// Node URL to connect to
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
#[arg(long)]
@@ -185,12 +181,12 @@ fn tool_definitions() -> Value {
},
{
"name": "node_info",
"description": "Get metadata about the connected Twigpine node: DID, version, network, protocols.",
"description": "Get metadata about the connected gitlawb node: DID, version, network, protocols.",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "node_health",
"description": "Check if the Twigpine node is alive and responding.",
"description": "Check if the gitlawb node is alive and responding.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -208,12 +204,12 @@ fn tool_definitions() -> Value {
},
{
"name": "repo_list",
"description": "List all repositories on the connected Twigpine node.",
"description": "List all repositories on the connected gitlawb node.",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "repo_list_federated",
"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.",
"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.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -266,7 +262,7 @@ fn tool_definitions() -> Value {
},
{
"name": "agent_register",
"description": "Register this agent with the Twigpine node to get a bootstrap UCAN token.",
"description": "Register this agent with the gitlawb node to get a bootstrap UCAN token.",
"inputSchema": {
"type": "object",
"properties": {
@@ -282,7 +278,7 @@ fn tool_definitions() -> Value {
},
{
"name": "agent_capabilities",
"description": "List all available UCAN capability strings in the Twigpine protocol.",
"description": "List all available UCAN capability strings in the gitlawb protocol.",
"inputSchema": { "type": "object", "properties": {} }
},
{
+10 -14
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 Twigpine node (authenticated)
//! 3. Create the repo on the gitlawb node (authenticated)
//! 4. `git push --mirror gitlawb://<did>/<name>`
//! 5. Clean up temp dir
@@ -22,19 +22,15 @@ pub struct MirrorArgs {
/// Source repository URL (https://github.com/owner/repo, GitLab, or any public git URL)
pub source: String,
/// Name to use on Twigpine (default: last path segment of source URL, .git suffix stripped)
/// Name to use on gitlawb (default: last path segment of source URL, .git suffix stripped)
#[arg(long)]
pub repo: Option<String>,
/// Optional description for the Twigpine repo
/// Optional description for the gitlawb repo
#[arg(long, short)]
pub description: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
#[arg(long)]
@@ -66,7 +62,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
let owner_did = kp.did().to_string();
println!("Mirroring {source}");
println!(" → Twigpine repo: {name}");
println!(" → gitlawb repo: {name}");
println!();
// ── 3. git clone --mirror into a temp dir ─────────────────────────────
@@ -93,8 +89,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 Twigpine ─────────────────────────────────────
println!("Creating repo on Twigpine node...");
// ── 4. Create the repo on gitlawb ─────────────────────────────────────
println!("Creating repo on gitlawb node...");
let client = NodeClient::new(&args.node, Some(kp));
let body = serde_json::to_vec(&json!({
@@ -117,7 +113,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
bail!("failed to create repo ({status}): {msg}");
}
// ── 5. git push --mirror to Twigpine ───────────────────────────────────
// ── 5. git push --mirror to gitlawb ───────────────────────────────────
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
println!("Pushing to {gitlawb_url}...");
@@ -129,7 +125,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 Twigpine 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 gitlawb but the push did not complete.\nYou can retry with:\n cd {path} && git push --mirror {gitlawb_url}",
path = mirror_path.display());
}
@@ -138,7 +134,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
println!();
println!("✓ Mirror complete: {name}");
println!(" Clone: git clone {gitlawb_url}");
println!(" View: https://twigpine.com/{owner_short}/{name}");
println!(" View: https://gitlawb.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,
/// Twigpine identity directory (reads your DID)
/// gitlawb identity directory (reads your DID)
#[arg(long)]
dir: Option<PathBuf>,
},
+4 -16
View File
@@ -20,11 +20,7 @@ pub struct NodeArgs {
pub enum NodeCmd {
/// Show a comprehensive status dashboard for the node
Status {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
@@ -33,21 +29,13 @@ pub enum NodeCmd {
/// Check trust score for a DID
Trust {
did: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
/// Resolve a DID to node info
Resolve {
did: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
@@ -324,7 +312,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
// ── Render dashboard ──────────────────────────────────────────────────
println!("╔══════════════════════════════════════════════╗");
println!("║ Twigpine node status ║");
println!("║ gitlawb 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 Twigpine node on Base L2...");
println!("Registering gitlawb node on Base L2...");
println!(" DID: {did}");
println!(" Stake: {stake} $GITLAWB");
println!(" HTTP URL: {http_url}");
+4 -20
View File
@@ -20,22 +20,14 @@ pub struct PeerArgs {
pub enum PeerCmd {
/// List known peers on the node
List {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.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.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -44,22 +36,14 @@ pub enum PeerCmd {
Ping {
/// The DID of the peer to ping
did: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.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.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
}
+8 -40
View File
@@ -35,11 +35,7 @@ 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.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -48,11 +44,7 @@ pub enum PrCmd {
List {
/// Repository name
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -63,11 +55,7 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -78,11 +66,7 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -93,11 +77,7 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -114,11 +94,7 @@ pub enum PrCmd {
/// Review body
#[arg(long, short)]
body: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -132,11 +108,7 @@ pub enum PrCmd {
/// Comment body
#[arg(long, short)]
body: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -147,11 +119,7 @@ pub enum PrCmd {
repo: String,
/// PR number
number: u64,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+6 -26
View File
@@ -1,6 +1,6 @@
//! `gl profile` — manage your agent profile (display name, bio, avatar, socials).
//!
//! Profile metadata is stored on the Twigpine node and optionally pinned to IPFS
//! Profile metadata is stored on the gitlawb node and optionally pinned to IPFS
//! for decentralized resolution. All writes are signed with your DID key.
use anyhow::{Context, Result};
@@ -59,11 +59,7 @@ pub enum ProfileCmd {
pin: bool,
/// Node URL
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
@@ -74,11 +70,7 @@ pub enum ProfileCmd {
/// Show your own profile
Show {
/// Node URL
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
@@ -92,11 +84,7 @@ pub enum ProfileCmd {
did: String,
/// Node URL
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
@@ -110,11 +98,7 @@ pub enum ProfileCmd {
pin: bool,
/// Node URL
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
@@ -125,11 +109,7 @@ pub enum ProfileCmd {
/// Export your profile as JSON
Export {
/// Node URL
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
+3 -15
View File
@@ -23,11 +23,7 @@ pub enum ProtectCmd {
/// Repository name or owner/repo
#[arg(long)]
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -38,11 +34,7 @@ pub enum ProtectCmd {
branch: String,
#[arg(long)]
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -51,11 +43,7 @@ pub enum ProtectCmd {
List {
#[arg(long)]
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.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.twigpine.com";
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
#[derive(Args)]
pub struct QuickstartArgs {
+3 -7
View File
@@ -1,4 +1,4 @@
//! `gl register` — register this agent identity with a Twigpine node.
//! `gl register` — register this agent identity with a gitlawb 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,11 +14,7 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct RegisterArgs {
/// Node URL to register with
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
/// Capabilities to advertise (comma-separated)
@@ -104,7 +100,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 Twigpine network.");
println!(" You are now a verified agent on the gitlawb network.");
Ok(())
}
+14 -66
View File
@@ -25,22 +25,14 @@ pub enum RepoCmd {
private: bool,
#[arg(long, default_value = "main")]
branch: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// List repositories on a node
List {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -48,11 +40,7 @@ pub enum RepoCmd {
/// Print the gitlawb:// clone command for a repo
Clone {
name: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -60,11 +48,7 @@ pub enum RepoCmd {
/// Show repository metadata
Info {
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -79,11 +63,7 @@ pub enum RepoCmd {
/// Max number of commits to show
#[arg(long, short, default_value = "20")]
limit: u32,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -95,11 +75,7 @@ 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.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -108,11 +84,7 @@ pub enum RepoCmd {
LabelAdd {
repo: String,
label: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -121,11 +93,7 @@ pub enum RepoCmd {
LabelRemove {
repo: String,
label: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -133,11 +101,7 @@ pub enum RepoCmd {
/// List labels on a repository
LabelList {
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -146,11 +110,7 @@ pub enum RepoCmd {
Owner {
/// Repository name or owner/repo
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -166,11 +126,7 @@ pub enum RepoCmd {
#[arg(long)]
url: String,
/// URL of the origin node (where the repo lives)
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -179,11 +135,7 @@ pub enum RepoCmd {
ReplicaUnregister {
/// Repository in owner/repo format
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -192,11 +144,7 @@ pub enum RepoCmd {
Replicas {
/// Repository in owner/repo format
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -317,7 +265,7 @@ async fn cmd_create(
println!("✓ Created repository: {name}");
println!(" Clone: git clone {gitlawb_url}");
println!(" HTTP: {clone_url}");
println!(" View: https://twigpine.com/{owner_short}/{name}");
println!(" View: https://gitlawb.com/{owner_short}/{name}");
if let Some(desc) = payload["description"].as_str().filter(|s| !s.is_empty()) {
println!(" Desc: {desc}");
}
+3 -15
View File
@@ -20,11 +20,7 @@ pub enum StarCmd {
Add {
/// Repository name (owner/repo or just name — owner derived from identity)
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -33,11 +29,7 @@ pub enum StarCmd {
Remove {
/// Repository name (owner/repo or just name — owner derived from identity)
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -46,11 +38,7 @@ pub enum StarCmd {
Count {
/// Repository in owner/repo format
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+3 -7
View File
@@ -10,11 +10,7 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct StatusArgs {
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -55,7 +51,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
}
}
// ── Current git repo + Twigpine remote ─────────────────────────────────
// ── Current git repo + gitlawb remote ─────────────────────────────────
let remote = detect_gitlawb_remote();
match &remote {
Some((did, repo)) => {
@@ -63,7 +59,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
println!(" repo {short_did}/{repo}");
}
None => {
println!(" repo (not in a Twigpine repo — no gitlawb:// remote)");
println!(" repo (not in a gitlawb repo — no gitlawb:// remote)");
}
}
+1 -5
View File
@@ -11,11 +11,7 @@ pub struct SyncArgs {
pub cmd: SyncCmd,
/// Node URL
#[arg(
long,
env = "GITLAWB_NODE",
default_value = "https://node.twigpine.com"
)]
#[arg(long, env = "GITLAWB_NODE", default_value = "https://node.gitlawb.com")]
pub node: String,
/// Identity directory for signed sync trigger requests
+6 -30
View File
@@ -38,11 +38,7 @@ pub enum TaskCmd {
/// ISO-8601 deadline for the task
#[arg(long)]
deadline: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -55,31 +51,19 @@ pub enum TaskCmd {
assignee_did: Option<String>,
#[arg(long, default_value = "50")]
limit: i64,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
/// View a specific task
View {
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
/// Claim a pending task
Claim {
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -89,11 +73,7 @@ pub enum TaskCmd {
id: String,
#[arg(long)]
result: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -103,11 +83,7 @@ pub enum TaskCmd {
id: String,
#[arg(long)]
reason: Option<String>,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+3 -15
View File
@@ -28,11 +28,7 @@ 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.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -42,11 +38,7 @@ pub enum VisibilityCmd {
path_glob: String,
#[arg(long)]
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -55,11 +47,7 @@ pub enum VisibilityCmd {
List {
#[arg(long)]
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+3 -15
View File
@@ -31,11 +31,7 @@ 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.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -44,11 +40,7 @@ pub enum WebhookCmd {
List {
/// Repository name
repo: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
@@ -59,11 +51,7 @@ pub enum WebhookCmd {
repo: String,
/// Webhook ID
id: String,
#[arg(
long,
default_value = "https://node.twigpine.com",
env = "GITLAWB_NODE"
)]
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
+1 -1
View File
@@ -1,4 +1,4 @@
# Run a Twigpine node + Postgres locally. For on-chain PoS operation, supply
# Run a gitlawb 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 @@
# Twigpine economics
# gitlawb economics
How $GITLAWB flows through the protocol and how stakers earn.
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer roadmap
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.
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.
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 `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`).
- `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`).
- `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 (`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.
- 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.
## Docker and self-hosting readiness
+4 -4
View File
@@ -1,4 +1,4 @@
# Running a Twigpine node
# Running a gitlawb 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://twigpine.com/install.sh | sh
curl -fsSL https://gitlawb.com/install.sh | sh
# or build from source:
cargo install --path crates/gl
```
## 2. Create a Twigpine identity
## 2. Create a gitlawb 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/twigpine/node:latest
ghcr.io/gitlawb/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/twigpine/node` image with Docker compose.
running the published `ghcr.io/gitlawb/node` image with Docker compose.
## What intentionally stays at the repo root
+2 -2
View File
@@ -1,4 +1,4 @@
# Twigpine node on AWS (Terraform)
# gitlawb 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/twigpine/node, pulled — not built)
7546/udp ├─ node (ghcr.io/gitlawb/node, pulled — not built)
└─ postgres:16-alpine
EBS gp3 volume mounted at /mnt/data
├─ node/ → container /data (repos + identity key)
+2 -3
View File
@@ -1,4 +1,4 @@
# Single-EC2 Twigpine node: Docker compose (node + postgres) on Amazon Linux 2023,
# Single-EC2 gitlawb node: Docker compose (node + postgres) on Amazon Linux 2023,
# persistent EBS data volume, Elastic IP, SSM access, daily snapshots.
# ---------------------------------------------------------------------------
@@ -164,7 +164,6 @@ resource "aws_iam_instance_profile" "node" {
# ---------------------------------------------------------------------------
resource "aws_security_group" "node" {
# description is ForceNew: changing it replaces the in-use group, so keep the original text.
name = "${var.name_prefix}-sg"
description = "gitlawb node: HTTP API + libp2p UDP"
vpc_id = data.aws_subnet.selected.vpc_id # follows subnet_id overrides into non-default VPCs
@@ -400,7 +399,7 @@ resource "aws_iam_role_policy_attachment" "dlm" {
}
resource "aws_dlm_lifecycle_policy" "data" {
description = "Daily snapshots of the Twigpine data volume"
description = "Daily snapshots of the gitlawb data volume"
execution_role_arn = aws_iam_role.dlm.arn
state = "ENABLED"
tags = local.common_tags
+1 -1
View File
@@ -5,7 +5,7 @@ name_prefix = "gitlawb-node"
instance_type = "t4g.small" # ARM/Graviton; node image is multi-arch
# --- Image -----------------------------------------------------------------
# image_repo = "ghcr.io/twigpine/node"
# image_repo = "ghcr.io/gitlawb/node"
# image_tag = "latest" # pin a version for production, e.g. "0.9.2"
# Or host the image in a private ECR repo in this account (image_repo is then
# ignored; build & push per README "Building and pushing the image"):
+2 -2
View File
@@ -66,7 +66,7 @@ variable "snapshot_retain_count" {
variable "image_repo" {
description = "Container image repository for the node. Ignored when create_ecr_repo is true (the module-managed ECR repo is used instead)."
type = string
default = "ghcr.io/twigpine/node"
default = "ghcr.io/gitlawb/node"
}
variable "create_ecr_repo" {
@@ -86,7 +86,7 @@ variable "image_tag" {
# ---------------------------------------------------------------------------
variable "icaptcha_mode" {
description = "iCaptcha gate mode: off | shadow | enforce. The live Twigpine network enforces."
description = "iCaptcha gate mode: off | shadow | enforce. The live gitlawb network enforces."
type = string
default = "enforce"
}
-1
View File
@@ -12,7 +12,6 @@ primary_region = "iad"
GITLAWB_P2P_PORT = "7546"
GITLAWB_REPOS_DIR = "/data/repos"
GITLAWB_KEY = "/data/keys/identity.pem"
GITLAWB_P2P_KEY = "/data/keys/p2p.key"
GITLAWB_PUBLIC_URL = "https://gitlawb-node-test.fly.dev"
GITLAWB_BOOTSTRAP_PEERS = "https://node.gitlawb.com,https://node2.gitlawb.com,https://node3.gitlawb.com"
GITLAWB_AUTO_SYNC = "true"
-1
View File
@@ -15,7 +15,6 @@ primary_region = 'sjc'
GITLAWB_HOST = '0.0.0.0'
GITLAWB_KEY = '/data/keys/identity.pem'
GITLAWB_MAX_PACK_BYTES = '524288000'
GITLAWB_P2P_KEY = '/data/keys/p2p.key'
GITLAWB_P2P_PORT = '7546'
GITLAWB_PORT = '7545'
GITLAWB_PUBLIC_URL = 'https://node2.gitlawb.com'
-1
View File
@@ -15,7 +15,6 @@ primary_region = 'nrt'
GITLAWB_HOST = '0.0.0.0'
GITLAWB_KEY = '/data/keys/identity.pem'
GITLAWB_MAX_PACK_BYTES = '524288000'
GITLAWB_P2P_KEY = '/data/keys/p2p.key'
GITLAWB_P2P_PORT = '7546'
GITLAWB_PORT = '7545'
GITLAWB_PUBLIC_URL = 'https://node3.gitlawb.com'
+6 -6
View File
@@ -1,10 +1,10 @@
<#
.SYNOPSIS
Twigpine installer for Windows - downloads pre-built binaries from GitHub Releases.
gitlawb installer for Windows - downloads pre-built binaries from GitHub Releases.
.EXAMPLE
irm https://twigpine.com/install.ps1 | iex
irm https://gitlawb.com/install.ps1 | iex
.EXAMPLE
& ([scriptblock]::Create((irm https://twigpine.com/install.ps1))) -Version v0.3.9
& ([scriptblock]::Create((irm https://gitlawb.com/install.ps1))) -Version v0.3.9
#>
[CmdletBinding()]
param(
@@ -16,7 +16,7 @@ $ErrorActionPreference = "Stop"
# Windows PowerShell 5.1 may default to TLS 1.0; GitHub requires TLS 1.2+.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Repo = if ($env:GITLAWB_RELEASE_REPO) { $env:GITLAWB_RELEASE_REPO } else { "Twigpine/node" }
$Repo = if ($env:GITLAWB_RELEASE_REPO) { $env:GITLAWB_RELEASE_REPO } else { "Gitlawb/node" }
$InstallDir = if ($env:GITLAWB_INSTALL_DIR) { $env:GITLAWB_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\gitlawb" }
$arch = if ($env:PROCESSOR_ARCHITEW6432) {
@@ -43,7 +43,7 @@ $ver = $tag.TrimStart("v")
$archive = "gitlawb-node-$ver-$target.zip"
$url = "https://github.com/$Repo/releases/download/$tag/$archive"
Write-Host "Installing Twigpine $tag for windows/x64"
Write-Host "Installing gitlawb $tag for windows/x64"
Write-Host " Archive: $archive"
Write-Host " Into: $InstallDir"
@@ -100,7 +100,7 @@ try {
}
Write-Host ""
Write-Host "Installed Twigpine $tag"
Write-Host "Installed gitlawb $tag"
foreach ($bin in $installed) { Write-Host " $bin -> $InstallDir\$bin" }
}
finally {
+8 -8
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# Twigpine installer - downloads pre-built binaries from GitHub Releases.
# gitlawb installer - downloads pre-built binaries from GitHub Releases.
#
# Usage:
# curl -fsSL https://twigpine.com/install.sh | sh
# curl -fsSL https://twigpine.com/install.sh | sh -s -- --version v0.3.9
# curl -fsSL https://gitlawb.com/install.sh | sh
# curl -fsSL https://gitlawb.com/install.sh | sh -s -- --version v0.3.9
set -euo pipefail
REPO="${GITLAWB_RELEASE_REPO:-Twigpine/node}"
REPO="${GITLAWB_RELEASE_REPO:-Gitlawb/node}"
INSTALL_DIR="${GITLAWB_INSTALL_DIR:-$HOME/.local/bin}"
VERSION_ARG="latest"
usage() {
cat <<EOF
Twigpine installer
gitlawb installer
Options:
--version <tag> Install a specific release tag, for example v0.3.9.
@@ -20,7 +20,7 @@ Options:
Environment:
GITLAWB_INSTALL_DIR Install directory (default: $HOME/.local/bin)
GITLAWB_RELEASE_REPO GitHub repo to download from (default: Twigpine/node)
GITLAWB_RELEASE_REPO GitHub repo to download from (default: Gitlawb/node)
EOF
}
@@ -97,7 +97,7 @@ ARCHIVE="${PACKAGE_NAME}.tar.gz"
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${TAG}/${ARCHIVE}"
CHECKSUM_URL="${DOWNLOAD_URL}.sha256"
echo "Installing Twigpine ${TAG} for ${OS_NAME}/${ARCH_NAME}"
echo "Installing gitlawb ${TAG} for ${OS_NAME}/${ARCH_NAME}"
echo " Archive: ${ARCHIVE}"
echo " Into: ${INSTALL_DIR}"
echo ""
@@ -161,7 +161,7 @@ if [ "${#INSTALLED[@]}" -eq 0 ]; then
fi
echo ""
echo "Installed Twigpine ${TAG}"
echo "Installed gitlawb ${TAG}"
for BIN in "${INSTALLED[@]}"; do
echo " ${BIN} -> ${INSTALL_DIR}/${BIN}"
done
+4 -8
View File
@@ -28,12 +28,8 @@ class Config {
// MARK: - Paths
/// Path to the gitlawb-node repo clone (for local builds), or nil when there is none.
///
/// Must not fall back to `dataDirectory`: the app's generated compose file lives
/// there, and treating it as a repo's own would skip `DockerCompose`'s handling
/// of that file (first-use generation and image migration).
var repoPath: URL? {
/// Path to the gitlawb-node repo clone (for local builds).
var repoPath: URL {
if !repoPathString.isEmpty {
return URL(fileURLWithPath: repoPathString)
}
@@ -52,7 +48,7 @@ class Config {
FileManager.default.fileExists(atPath: dockerfile.path) {
return repoRoot
}
return nil
return dataDirectory
}
var dataDirectory: URL {
@@ -126,7 +122,7 @@ class Config {
let envPath = dataDirectory.appendingPathComponent(".env")
var lines = [String]()
lines.append("# Generated by Twigpine Node app — do not edit manually")
lines.append("# Generated by Gitlawb Node app — do not edit manually")
lines.append("POSTGRES_PASSWORD=\(postgresPassword)")
lines.append("GITLAWB_PUBLIC_URL=\(publicURL)")
lines.append("GITLAWB_PORT=\(httpPort)")
@@ -19,7 +19,7 @@ enum NodeStatus: Equatable {
}
}
/// Manages the Docker Compose lifecycle for the Twigpine node stack.
/// Manages the Docker Compose lifecycle for the gitlawb node stack.
class DockerCompose {
/// Current status, updated by polling.
private(set) var status: NodeStatus = .stopped {
@@ -39,48 +39,25 @@ class DockerCompose {
/// Path to the docker-compose.yml — uses the repo's file if available, otherwise generates one.
var composeFilePath: String {
// Prefer the repo's own docker-compose.yml (supports `build: .`)
if let repo = repoWithCompose {
return repo.appendingPathComponent("docker-compose.yml").path
let repoCompose = Config.shared.repoPath.appendingPathComponent("docker-compose.yml")
if FileManager.default.fileExists(atPath: repoCompose.path) {
return repoCompose.path
}
// Fallback: the app's own compose file with the pre-built image. Generate it
// on first use. Afterwards keep it: it is the only place an operator can pass
// settings the generated environment block omits (e.g.
// GITLAWB_OPERATOR_STRICT_MODE), so only the retired image path is migrated.
// Fallback: generate a compose file with pre-built image
let userPath = Config.shared.dataDirectory.appendingPathComponent("docker-compose.yml").path
if FileManager.default.fileExists(atPath: userPath) {
Self.migrateRetiredImage(atPath: userPath)
} else {
try? FileManager.default.createDirectory(at: Config.shared.dataDirectory, withIntermediateDirectories: true)
let content = Self.generateComposeFile()
try? content.write(toFile: userPath, atomically: true, encoding: .utf8)
}
try? FileManager.default.createDirectory(at: Config.shared.dataDirectory, withIntermediateDirectories: true)
let content = Self.generateComposeFile()
try? content.write(toFile: userPath, atomically: true, encoding: .utf8)
return userPath
}
/// Rewrites references to the pre-rename image, which no longer serves anonymous
/// pulls, to the image `release.yml` publishes. Tags and digests are kept.
private static func migrateRetiredImage(atPath path: String) {
guard let existing = try? String(contentsOfFile: path, encoding: .utf8) else { return }
let migrated = existing.replacingOccurrences(
of: #"(?<![A-Za-z0-9_./-])ghcr\.io/gitlawb/node(?![A-Za-z0-9_./-])"#,
with: "ghcr.io/twigpine/node",
options: .regularExpression
)
if migrated != existing {
try? migrated.write(toFile: path, atomically: true, encoding: .utf8)
}
}
/// Project directory for docker compose (needed for `build: .` context).
var projectDirectory: String {
(repoWithCompose ?? Config.shared.dataDirectory).path
}
/// The repo clone, if there is one and it has its own docker-compose.yml.
private var repoWithCompose: URL? {
guard let repo = Config.shared.repoPath else { return nil }
let compose = repo.appendingPathComponent("docker-compose.yml")
return FileManager.default.fileExists(atPath: compose.path) ? repo : nil
let repoCompose = Config.shared.repoPath.appendingPathComponent("docker-compose.yml")
if FileManager.default.fileExists(atPath: repoCompose.path) {
return Config.shared.repoPath.path
}
return Config.shared.dataDirectory.path
}
/// Path to the .env file with user configuration.
@@ -285,7 +262,7 @@ class DockerCompose {
private static func generateComposeFile() -> String {
return """
# Generated by Twigpine Node macOS app
# Generated by Gitlawb Node macOS app
services:
postgres:
image: postgres:16-alpine
@@ -303,7 +280,7 @@ services:
restart: unless-stopped
node:
image: ghcr.io/twigpine/node:latest
image: ghcr.io/gitlawb/node:latest
depends_on:
postgres:
condition: service_healthy
+2 -2
View File
@@ -3,9 +3,9 @@
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Twigpine Node</string>
<string>Gitlawb Node</string>
<key>CFBundleDisplayName</key>
<string>Twigpine Node</string>
<string>Gitlawb Node</string>
<key>CFBundleIdentifier</key>
<string>com.gitlawb.node-app</string>
<key>CFBundleVersion</key>
@@ -1,4 +1,4 @@
# Run a Twigpine node + Postgres locally. For on-chain PoS operation, supply
# Run a gitlawb node + Postgres locally. For on-chain PoS operation, supply
# the operator key + contract addresses via a .env file or shell environment.
services:
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more