mirror of
https://github.com/Twigpine/node.git
synced 2026-11-13 06:51:46 -10:00
78cde8dee89be75e439bb37fd080986e9c9c97a3
1
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
55ca89bfff |
test(node): real-node deny harness for trust-boundary regressions (owner-gated mutations, path-scoped reads, client-surfaced denials) (#194)
* refactor(node): split gitlawb-node into lib+bin so integration tests can spawn a real node
Move the module tree and boot logic from main.rs into a new lib.rs crate
root exposing the boot surface (build_router, AppState, Config, migrations)
as pub; main.rs becomes a thin #[tokio::main] shim over run(). No behavior
change: both targets build and the full node suite (488 tests) stays green.
Prerequisite for the real-node deny harness (U1).
* test(node): real-node deny harness rig + INV-8 unsigned-push case (U2-U4, U5a)
Add a feature-gated (test-harness) spawn surface (src/test_harness.rs) that
boots a real node on 127.0.0.1:0 over an ephemeral #[sqlx::test] pool through
the production axum::serve stack with connect-info, and an integration crate
(tests/deny_harness.rs) that drives deny paths with a real reqwest client:
- U2 signing client: wraps gitlawb_core::http_sig::sign_request for reqwest;
self-checks that a valid signature clears require_signature and a tampered
body is rejected (400 content_digest_mismatch).
- U3 spawn_node: real socket, p2p disabled, per-test DB, shutdown-on-drop.
- U4 assert_denied: 4xx AND body-no-leak AND not-empty-200 (INV-8); pure core
unit-tested for clean-403 / empty-200 / leaking-403 / wrong-status.
- U5a INV-8: unsigned git-receive-pack is denied 401 with no leak.
Widens the three cfg(test) test builders (Db/RepoStore::for_testing,
run_migrations) to also compile under the feature. No production behavior
change: prod build (no feature) excludes test_harness; node suite stays green
(488) and the 7 integration tests pass.
* test(node): INV-1 owner-gate deny case over the real stack (U6)
A validly signed non-owner PUT /visibility is rejected 403 by require_owner
(no x-ucan, so require_ucan_chain passes through to the gate); the owner's
signed PUT reaches the handler (reachability proof, guards against a 404/415
masquerading as a pass). Adds seed_repo/withhold_path seeding helpers to the
test harness. Mutation-verified load-bearing: with require_owner forced Ok the
non-owner PUT returns 201 and the INV-8 assertion flips the test RED.
* test(node): INV-2 read + content-addressed withhold deny cases (U5b, U7)
Adds seed_bare_repo (shells git to build a real bare repo at the served path,
sha1 or sha256 object format) and two INV-2 deny cases over the real stack:
- U7: a public repo with a /secret/** withhold rule denies an anonymous blob
read of the withheld path (404) with no content/OID leak, while the sibling
public path is served (path-scoped, not blanket).
- U5b: the same withhold denies an anonymous /ipfs/{cid} read of the withheld
blob's content-addressed id (404, no leak), while the public blob's CID is
served. Completes U5 (INV-8) alongside U5a.
Both mutation-verified load-bearing: forcing visibility_check to allow leaks
the secret at 200 and the INV-8 assertion flips each test RED.
* test(node): INV-2 replication-path deny case over git-upload-pack (U8)
Drives the git-upload-pack POST directly (v0 stateless-RPC: want HEAD, flush,
done) via a bounded reqwest client rather than a vanilla `git clone` (which
negotiates protocol v2 and deadlocks against the node's v0 server, and would
otherwise wedge the suite). The served pack is indexed with git index-pack and
its objects listed with verify-pack -v: a packfile-aware assertion, since a raw
byte scan cannot see an OID inside the zlib-compressed stream.
A public repo with a /secret/** withhold rule must serve a pack that omits the
withheld blob's object while keeping the sibling public blob. Mutation-verified
load-bearing: forcing visibility_check to allow puts the withheld blob back in
the pack and flips the test RED.
Completes the harness (8 units, 11 integration tests). Prod build (no feature)
and the 488-test node suite stay green.
* ci(node): run the real-node deny harness on every PR
cargo test --workspace skips the harness because it lives behind the
test-harness feature (kept off the production binary). Add an explicit step
that runs it with the feature and the same Postgres service, so the INV-1/
INV-2/INV-8 trust-boundary regression cases execute on every PR instead of
only when run by hand.
* test(node): cover the deny-assertion guard branches (U4)
Add unit tests for the two remaining check_denied branches: a non-4xx expected
status is rejected as a test bug, and an empty withheld token is skipped rather
than matching every body. Closes the last unexecuted branches in the deny
assertion.
* test(node): full-stack owner-gate deny cases for 5 high-value mutations
Fan-out of U6 to the security-sensitive owner-gated mutations that had only the
source-level authz-table guard and no runtime deny test: protect_branch,
unprotect_branch, create_webhook, delete_webhook, remove_visibility. Each
rejects a validly-signed non-owner with 403 and lets the owner reach the
handler (not 403). Mutation-verified load-bearing on their shared root gate:
did_matches forced true opens all five (non-owner protect_branch returns 201)
and the test flips RED.
Replica register/unregister were intentionally excluded: they are signer-self
(you register your own node), not owner-gated, so there is no owner-deny to
assert.
* style(node): cargo fmt the deny-harness sources
* Address PR review feedback (#194)
- Create the node identity key atomically with create_new + mode 0600, closing
the umask-derived 0644 disclosure window and the exists()->write overwrite
race; on AlreadyExists load the winner's key (bounded retry so a loser can't
read a half-written PEM) and tighten looser perms on load.
- Enforce the configured shutdown grace: bound the axum drain by grace measured
from the signal (extracted as drive_serve_with_grace), abandoning in-flight
requests once it expires instead of waiting indefinitely. Removes the
discarded grace value.
- Route the deny-harness reqwest clients through a shared bounded_client (30s
timeout) so a wedged node path fails fast instead of hanging to the CI limit.
Tests: 0600-on-create, load-tighten, concurrent-start convergence (create race),
and grace-race abandon / normal-drain / signal-gated-clock. All RED-then-GREEN
by execution.
* fix(node): remove a failed first-write key file; verify key mode after tightening (#194)
F1 (P1): create_new(true) closed the permission window, but a write_all failure
after the file was created left an empty/partial PEM behind. Every later start
then took the key_path.exists() branch, re-parsed that corrupt file in load_racing,
and exited 'invalid PEM key' instead of regenerating — a transient ENOSPC/EIO on
first boot permanently wedged the node. Extract write_key_or_cleanup, which removes
the just-created file on write failure, and wire it into both the unix and non-unix
create branches.
F2 (P2): the load path tightened a loose existing key to 0600 but discarded the
set_permissions result. A chmod that failed (read-only mount, ownership/ACL
mismatch) left a world/group-readable private key in use while logging a normal
'loaded existing identity'. Surface the tighten failure (propagate it) and add
ensure_key_mode_0600, which fails closed if the key is not 0600 after the attempt.
RED->GREEN: failed_write_removes_the_partial_key_file (a failed write leaves no
file; RED without the remove_file). loose_key_mode_is_rejected_not_used (a 0644 key
is rejected; RED without the mode check). Existing created_key_is_mode_0600,
existing_key_is_loaded_and_tightened, and concurrent_starts_converge_on_one_identity
stay green. Full node lib+bin suite 497 passed, fmt + clippy clean.
* fix(node): publish the identity key atomically and wait a real deadline
The create path did create_new(final)+write_all, so the final path appeared as
an empty inode before the PEM was flushed, and a losing/fast-path start only
retried ~100ms (50x2ms). On a slow or stalled filesystem the winner's write can
exceed that window, so every other start failed boot with 'invalid PEM key'
(#194). Publish atomically instead: write the full PEM to a sibling temp, then
hard_link it into place. hard_link is atomic and fails if the target exists, so
the final path only ever appears COMPLETE (no partial-read window), a lost race
never clobbers the winner, and a crashed writer leaves only a temp rather than a
partial final that would wedge later starts. load_racing_key now polls on a
wall-clock KEY_RACE_DEADLINE (5s) instead of a fixed 100ms count. Hoisted
load_existing_key/load_racing_key to module level for testability.
Adversarial RED->GREEN: a 250ms-slow winner is waited out (RED at the old 100ms);
a reader watching the final never sees a partial file (RED with create_new+write);
a losing publish does not clobber the winner; 500 tests pass.
* fix(node): recover key publish from stale temps, make it durable; join TestNode teardown (#194)
- pick the temp key name with a bounded per-call retry instead of a
process-global counter: a crashed start's leftover temp no longer wedges
a PID-stable restart, and cross-container publishers stop colliding
- fsync the temp PEM before it can be linked and the key directory after
publication, so a power crash cannot leave a durable-but-truncated
identity.pem that permanently blocks startup
- TestNode::shutdown() joins the serve task and closes the pool before
sqlx's DROP DATABASE cleanup runs; every deny-harness test now ends
with it instead of relying on drop-only teardown
* test(node): let the partial-final reader wait out the fsynced publish (#194)
The added temp-file and directory fsyncs can push the hard_link past the
reader's old 400ms window under parallel-suite disk load, flaking
publish_never_exposes_a_partial_final (observed ~1 in 5 local runs). The
reader now polls a 5s deadline and stops at the first complete
observation; the never-see-partial assertion still runs before the
break, so the exposure mutation still fails the test.
Also moves the allow-unbounded-git marker in seed_bare_repo directly
above the spawn line, where the pre-push gate looks for it.
* fix(node): fall back to a direct create_new publish when hard_link fails (#194)
On a non-AlreadyExists hard_link error, publish the identity key by
creating the final path directly with create_new(0600): no-clobber is
preserved via the exclusive create (AlreadyExists routes to the lost-race
load), write failures remove the partial final, and fsync failures keep
the complete final on disk since a concurrent loser may already have
loaded it (the Won-arm policy). The fallback chains the original link
error and the rustdoc now scopes the complete-only-visibility guarantee
to the hard-link path. Link step is injectable via compile-time fault
hooks; six new tests drive both fallback arms, the lost race, the
concurrent convergence, and both failure phases.
* test(node): abort the server task in TestNode::drop (#194)
A test that returns without calling shutdown() previously relied on the
watch signal alone to unwind the server before sqlx's DROP DATABASE.
Drop now also aborts any remaining serve handle, so teardown holds even
when the graceful chain is broken; signal and abort are independently
sufficient (verified by a four-quadrant neuter matrix in the new
drop_without_shutdown_unblocks_database test, which observes leaked
sessions via a standalone connection with sqlx pool reaping disabled).
* chore: sync Cargo.lock with the 0.5.1 workspace manifests
* test(node): surface axum::serve's result through TestNode::shutdown (#194)
The spawned serve task previously discarded its io::Result; shutdown()
now joins and checks it. On axum 0.8.8 the Err arm is unreachable
(graceful shutdown hard-codes Ok and accept errors retry forever), so
this is forward-compat insurance documented as such inline; the Ok path
is exercised by every harness test.
* fix(node): verify key file mode before writing PEM bytes at both creation sites (#194)
Both creation sites (the temp on the hard-link path, the final on the
fallback path) now run the loader's tighten-then-verify pair right after
create_new and before any private bytes are written: set_permissions
repairs a umask-narrowed mode (0277 landed 0400 and previously shipped
that way), and on mode-ignoring mounts where the tighten silently no-ops
the verify fails closed with the empty file removed so retries are not
wedged. A verified temp implies a verified hard-linked final (shared
inode). Both call sites are mutation-vetted; umask cases run in child
processes because umask is process-global.
* docs(node): state the real shutdown-grace contract and lock it with a real-socket test (#194)
GITLAWB_SHUTDOWN_GRACE_SECS promised a 503 to in-flight clients at
expiry; drive_serve_with_grace (correctly) abandons them instead, and a
retroactive 503 on an in-flight response is not constructible under
hyper. The config prose now states the abandon contract, and a new test
drives a real listener with a slow handler through the driver at a
250ms grace, asserting the client never sees a 503 status line and the
driver returns bounded with grace_expired set.
This commit changes only a doc comment and a test, so it is typed docs()
rather than fix() to avoid a spurious release-please patch bump.
* test(node): close review gaps on the key-publish fallback and Drop abort (#194)
Round-five review follow-ups, no production behavior change beyond a
pure fsync_parent_dir extraction:
- Recondition the KEY_RACE_DEADLINE and publish_key_atomically rustdocs:
a present key is complete-on-first-read only on the hard-link path; on
the fallback the final name is visible mid-write (the deadline covers
it) and the fallback also weakens durability (d), failing loud at next
start, never a silently used key.
- Cover the fallback create_new open-error arm via a new fault hook,
asserting the returned error chains the original link error.
- Make the Drop abort load-bearing (INV-21): a test-only flag suppresses
the graceful signal so handle.abort() carries teardown alone; removing
the abort turns the new test RED while the existing one stays green.
- Observe the crash-residue contract: empty/garbage finals fail loudly.
- Extract fsync_parent_dir, shared by the Won and fallback arms.
- Split the permissive-umask test so a 0000 regression can't hide behind
0077.
* fix(node): recover identity-key fallback fsync failure and abort wedged serve task
F1: the hard-link fallback wrote the PEM directly to the final path then
fsync'd it separately; an fsync failure left a durable name over non-durable
content, so a later crash wedged startup (load_or_create_keypair parses the
existing truncated file instead of regenerating). Fold the file fsync into
write_key_or_cleanup so the final is removed on fsync failure, matching the
hard-link temp policy; the next start regenerates. Parent-dir fsync still
keeps-on-failure.
F2: TestNode::shutdown moved the serve JoinHandle into tokio::time::timeout and,
on elapse, the panicking .expect dropped it, detaching the still-running task
and leaking the test DB across DROP DATABASE. Retain the handle (&mut), abort
and reap it on timeout before panicking. Also: honor a task that finishes in the
timeout race as graceful (not a false wedge), propagate a real serve-task panic,
and close the pool on every path (including the wedged arm) before returning or
panicking so the leak is closed on the path that needs it most.
* test(node): invariant deny-prober (driven registry + completeness cross-check) (#195)
* test(node): U1 deny-bearing route registry (owner-gate + signature tranches)
Foundation for the invariant deny-prober (plan 002): a declarative registry of
ONLY the routes that carry a runtime deny (owner-gate 403 / read-gate 404 /
signature 401), each classified by reading its handler and recording the fn
name. Owner-gate (12 rows: merge/close_pr, close_issue, webhooks, labels,
protect, visibility set/remove/list incl the GET-is-403 correction) and
signature-required (git-receive-pack) tranches are verified; read-gate tranche
stubbed with a TODO (per-handler 404-vs-filter verification pending). A
consistency self-test asserts no dup method+path, every row names its handler,
and every read-gate row carries a positive-twin Reach. Decoupled from the #194
primitives (U1 uses none), so classification is not hostage to harness churn.
* test(node): U2 fixture state matrix + per-gate-class probe generators
Two-repo fixture (public owner-gate substrate + private read-gate substrate)
plus probes_for(): each deny-bearing row expands to a hostile probe asserting
the exact deny status and a positive twin (owner-reachability for owner-gate,
authorized read for read-gate) so a deny for the wrong reason cannot false-pass.
Three self-checks cover the owner-gate, read-gate, and signature classes.
* test(node): U1 read-gate tranche (14 repo-scoped 404-deny reads)
Each row's handler was read to confirm it gates on "/" via
authorize_repo_read / visibility_check and returns RepoNotFound (404) to a
non-reader of a private repo, with the owner re-read as the ReaderReads
positive twin. Sub-entity gets (get_issue/get_pr/get_cert/get_bounty and the
comment/review lists) are deferred to the fixture expansion that seeds them;
global list-filter surfaces and the KNOWN_UNGATED reads are excluded with
reasons. Runtime behavior is asserted by U3.
* test(node): U3 drive the deny-bearing registry over a real node
Boots a real node, seeds the two-repo fixture (plus a PR and an issue so the
author-or-owner close gates are reached, not 404'd on an absent entity), then
walks every deny-bearing route: the hostile probe must return the exact deny
status and leak nothing, and the positive twin must reach the handler (owner:
not 403; read: 2xx). A terminal invariant asserts one hostile probe per row
was driven and the count equals the registry, so a row that produced no probe
fails loud rather than passing vacuously. Adds seed_pr/seed_issue to TestNode.
Mutation-verified load-bearing: breaking did_matches drove the merge_pr
owner-gate hostile to 500 (RED, named); neutering authorize_repo_read's deny
drove the get_repo read-gate hostile to 200 (RED, named); both reverted green.
* test(node): U4 completeness cross-check keeps the registry from drifting
A pure source scrape of server.rs (multiline .route parser, floor 90) that the
runtime sweep cannot give: no deny-bearing row points at a route that no longer
mounts (anti-stale), and no handler carrying an unambiguous owner-gate marker
(require_repo_owner / require_owner) escapes the registry (orphan). The api dir
is read at test time so a new module is covered. Complements authz_guard (which
proves handlers are gated) by proving the deny-bearing ones are actually driven,
and reaches the non-API git mounts authz_guard never sees.
Mutation-verified: a bogus row path tripped anti-stale (named the row); removing
the merge_pr row tripped the orphan guard (named merge_pr); both reverted green.
* test(node): close vacuous-green gaps in the deny prober's own assertions
Vetting the harness against itself surfaced branches that ran green but were
never exercised adversarially:
- Ok2xx twin dropped its unused token Option; the arm now asserts the authorized
read returns a NON-EMPTY 2xx, so an empty-200 denial-rendered-as-success is
caught (executed by every read twin).
- U4 orphan guard gains a floor on the owner-marker scan (>=6), so a marker-scan
regression to zero fails loud instead of passing by checking nothing.
- Added probe unit tests that execute the previously-uncovered probes_for arms:
the SiblingPublic path-scoped twin, and the Reach::None fail-loud panic.
All 22 harness tests green. Every send_probe/probes_for branch is now executed;
the Deny(401), Not403, and Ok2xx-status arms were additionally mutation-verified
RED (signature-expect flip; did_matches->false; authorize_repo_read->deny) and
both U4 floors RED via threshold flips, all reverted.
* test(node): close 3 completeness gaps in the deny prober's own guards (#195)
jatmn's review flagged three ways the prober's self-guards stay green when
the protection they check is removed:
- F1: the U4 completeness check enforced owner-gate orphans but not
read-gate ones, so dropping authorize_repo_read on get_issue/get_cert/
get_bounty/etc. left the sweep green. Add a symmetric read-gate orphan
guard that scans src/api for authorize_repo_read/visibility_check handlers
and requires each to be a driven ReadGate row or an enumerated
READ_GATE_NOT_DRIVEN entry with a reason, replacing the free-text prose.
A staleness check keeps the allowlist from carrying dead exemptions.
- F2: the U3 registry sweep called assert_denied with an empty withheld
list, proving status only. Seed the private fixture with a distinctive
secret and its blob OID, carry them on the read-gate hostile probes, and
pass them through so a denial body echoing withheld data fails.
- F3: the owner-gate orphan scan matched only require_owner/require_repo_owner
and missed the inline did_matches(caller, &record.owner_did) idiom that
protect_branch/unprotect_branch use. Fold in a has_owner_did_matches
discriminator that catches the owner form without false-matching the
signer-self (register_replica) or author (close_pr) did_matches forms,
pinned by unit tests on the exact bodies.
* test(node): make the deny-prober guards load-bearing (#195, F1-F4)
Resolves jatmn's four INV-21 completeness findings — guards that were green but
not load-bearing:
F1: every ReadGate hostile probe was anonymous, so the signed-non-reader branch of
each private read was untested — a regression treating any valid signature as
authorized would leak while the sweep stayed green. Add a Signer::Stranger 404/
no-leak probe per read row, with a dedicated readgate_stranger counter so a dropped
probe fails loudly. RED: authorize any signed caller -> the stranger probe leaks
(200) while the anon probe stays green.
F2: get_star_status, get_icaptcha_proof, and replicate_encrypted_blobs were
source-only exemptions (a marker scan, never driven). Drive them as real ReadGate
rows and remove the allowlist entries. RED: a runtime bypass that keeps the
authorize_repo_read marker leaks -> the driven probe RED, the source scan green.
F3: git_receive_pack's protected-branch owner 403 was never driven (its registry
row is the unsigned-401 path). Add signed_stranger_protected_branch_push_is_forbidden
(a signed non-owner push to a protected branch -> 403, owner control -> not-403),
plus a seed_protected_branch harness helper. RED: invert the gate -> the probe RED.
F4: the read-gate no-leak assertion omitted the private repo id (seed discarded it).
Preserve it on the Fixture and withhold it; a synthetic 404 body carrying the UUID
must fail check_denied. RED: a UUID-bearing body passed before the token was added.
Each guard proven load-bearing by reverting the exact production auth-line (reverts
not shipped). deny_harness suite 30 passed, fmt + clippy clean.
* test(node): drive every arm of the multi-principal close/dispute gates
close_pr, close_issue (owner-or-author) and dispute_bounty (creator-or-claimant)
were each driven with a single owner/creator twin, so reverting the untested arm
stayed green. Model gates by their authorizing-principal set (MultiPrincipalGate
+ Principal), seed distinct author/creator/claimant identities, and emit one
Not403 twin per arm plus the stranger 403. registry_internal_consistency now
invokes probes_for and asserts a twin per declared arm, so a future multi-arm
handler cannot register with an untested arm.
Each arm proven load-bearing: reverting is_owner/is_author on close_pr/close_issue
and is_creator/is_claimant on dispute_bounty turns that arm's twin RED.
* test(node): drive the deferred private reads with per-read leak assertions
The deferred reads (get_issue, get_pr, get_pr_diff, list_issue_comments,
list_reviews, list_comments, get_cert, get_bounty, path-scoped get_tree) sat in
READ_GATE_NOT_DRIVEN, checked only for an authorize_repo_read marker, so an
ignored result or early return stayed green. Seed each sub-entity in the private
repo (issues/PRs via the real create handlers, a cert via a signed owner push,
a private-repo bounty), thread id-keyed paths through IdSource, and drive each as
a ReadGate row: anon/signed-stranger 404, owner 2xx.
Each seeded entity carries a distinctive marker added to the read-gate withheld
set, so a 404 that leaks the entity's own content fails (not just a status
check). get_pr_diff exercises both its repo-root gate and its per-path
visibility_check loop. get_encrypted_blob stays deferred (owner twin needs live
IPFS; reclassified structurally next).
Load-bearing: an ignored-result bypass on each read turns its hostile probe RED;
a 404 leaking a seeded marker fails the body assertion.
* test(node): close the prose-excuse hatch with a mounted-route completeness gate
READ_GATE_NOT_DRIVEN allowed a free-text reason to park a read-gated handler out
of the runtime sweep. Replace the reasons with a closed NotDrivenReason enum
(ReadGatingMutation, GitSmartHttpRead, ContentAddressedRead, GlobalListFilter,
ExternalDependencyUnavailable); get_encrypted_blob moves to the last. A drivable
404-deny GET no longer type-checks a reason and is caught by a driven-not-
relabeled assertion.
Add a completeness gate that derives the required read-gate set from the mounted
route table (scrape_mounts, INV-21b), not only the source-marker scan: every
mounted repo-scoped GET must be a driven ReadGate row, a deny-bearing GET,
structurally excused, or a declared public GET — else it fails. Catches a read
that gates via a helper the marker scan does not recognize.
Load-bearing: re-parking a driven read trips the relabel assert; removing a
driven ReadGate row trips the mounts cross-check.
* test(review): harden multi-principal twin check against signer collision
Add a distinct-signer assertion to multi_principal_rows_emit_one_twin_per_arm:
the per-arm any() check is satisfied by a single twin when two Principals map to
the same Signer, so a signer_for_principal collision would leave one arm silently
untested. Assert the emitted twins carry principals.len() distinct signers
(proven RED by colliding Author->Owner). Also correct the get_cert / priv_markers
comments, which claimed a withheld cert-content marker that is not seeded — no
cert marker is needed because get_cert 404s at the repo-root gate before the cert
is fetched, so cert fields cannot reach the deny body.
* style(test): apply rustfmt to the deny-prober additions
* test(node): drive the bounty mutation gates and pin fixture object format (#195)
- submit/approve/cancel_bounty become MultiPrincipalGate registry rows
with status-appropriate bounty fixtures (claimed/submitted/open): their
status check fires before the auth gate, so each row seeds its own
bounty through the real API and the arm twins consume the status
- pin --object-format=sha1 in both fixture worktrees so a sha256 default
object format cannot break the OID equality with the served sha1 repos
* test(node): drive the caller-self task/register gates in the deny-prober (#195)
Resolve the three review findings on head
|