Compare commits

..
Author SHA1 Message Date
beardthelion 361805b1bd chore: sync Cargo.lock with manifests (0.5.1 + icaptcha-client sha2 dep)
The committed lockfile lagged the manifests: workspace crates were bumped to
0.5.1 and icaptcha-client's Cargo.toml declares sha2, but the lock still
carried 0.5.0 and no sha2 entry. Every build regenerated it, leaving
Cargo.lock perpetually dirty. Regenerated offline (no dependency upgrades) so
the tree stays clean.
2026-08-28 23:16:43 -05:00
beardthelion 526ee87344 fix(node): make the SDL mutation-fence wiring load-bearing; admit deprecated
An adversarial review of the SDL switch found three gaps:

- The fence's completeness source (SDL) and its hidden-mutation cross-check were
  not load-bearing: because the real MutationRoot has no visible=false field,
  reverting `fields` back to introspection and dropping the cross-check left the
  whole suite green (INV-21). Extract the sourcing + cross-check into a generic
  `assert_mutation_fence<Q,M,S>` that the real fence AND a synthetic hidden schema
  both drive. A new #[should_panic] test (fence_rejects_a_hidden_mutation) feeds a
  visible=false mutation that IS registered, so only the cross-check can fire;
  reverting the SDL source or deleting the cross-check now changes/removes that
  panic and turns the test RED (verified).

- The introspection cross-check used bare `fields { name }` (GraphQL default
  includeDeprecated:false), so a `#[graphql(deprecation = "...")]` mutation --
  executable and present in SDL -- would false-red as a hidden visible=false
  mutation. Use `fields(includeDeprecated: true)`; a new test
  (fence_admits_a_deprecated_mutation) pins it, going RED without the flag.

- schema_lists_exactly_the_registered_mutations still sourced from introspection
  and its comment called introspection "the source of truth", contradicting the
  fix. Retargeted at the SDL set with a corrected comment.

Also folds the two introspection navigations into one generic helper
(introspection_mutation_fields) and points the version-safety test at it, so
there is a single introspection query. cargo test -p gitlawb-node --bins green
(497); clippy and fmt clean. Only api/mod.rs changed.
2026-08-28 23:16:38 -05:00
beardthelion 70b01aa320 fix(node): source the GraphQL mutation fence from SDL, not introspection
Resolves jatmn's #219 P1. The every_graphql_mutation_has_its_gate completeness
fence enumerated mutation fields via async-graphql introspection, which filters
out `#[graphql(visible = false)]` fields. Those fields stay executable, so a
future hidden mutation would need no registry row and no marker while both
set-equality checks stayed green -- defeating the fence's guarantee that no live
mutation goes unclassified. (Confirmed by execution: a synthetic visible=false
mutation is absent from introspection, executable, and present in schema.sdl().)

Source completeness from the engine's own SDL instead. A new pure helper
mutation_field_names_from_sdl parses schema.sdl() with async-graphql's parser
AST (async_graphql::parser::parse_schema) and reads the mutation type's fields;
SDL includes visible=false fields and the AST parse is immune to a brace in a
field description. Introspection is kept as a cross-check: any field present in
SDL but hidden from introspection now fails named as a forbidden hidden
mutation. The stale doc comments (which claimed introspection could not hide a
field) are corrected.

A self-checking version-safety test builds a synthetic schema with a
visible=false mutation and asserts the SDL-sourced set includes it while
introspection omits it -- so if a future async-graphql ever filters SDL by
visibility, that test goes RED rather than the gap reopening silently.

Load-bearing verified by execution: stubbing the SDL enumeration to empty turns
four tests RED, including the fence itself; restoring returns green. Only
api/mod.rs changed; the mutation resolvers are untouched. `cargo test -p
gitlawb-node --bins` green (495); clippy and fmt clean.
2026-08-28 23:16:38 -05:00
beardthelion a31fed4a67 docs(node): reconcile GraphQL mutation-auth claims with the shipped state
README's known limitations, the OSS-readiness live-network blockers, and the
maintainer roadmap still listed GraphQL mutation auth as an open item, which
contradicts #87: GraphQL mutations already require a verified signer bound to the
acting DID. Reconcile each to the shipped reality while keeping the genuinely-open
nuance: the GraphQL mutation surface is the agent-task queue, repo writes stay
REST-only, and no owner-gated repo-write mutation is exposed over GraphQL. The
every_graphql_mutation_has_its_gate fence forces any new mutation to be classified
in its table; owner-gate and REST parity stay the INV-1 review check.
2026-08-28 23:16:36 -05:00
beardthelion 626c545553 test(node): add a completeness fence over the GraphQL mutation surface
Gate the GraphQL mutation surface the way REST is gated, as a SEPARATE guard with
its own bucket table (a guard over a heterogeneous set must classify per member,
INV-1). every_graphql_mutation_has_its_gate asserts the mutation set is complete
(every mutation classified) and that each carries its gate marker.

Completeness is derived from async-graphql's own introspection: the field names
come from the engine as structured data, so no source-text or SDL-format shape
(a brace or async fn in a comment or string, a whitespace/generic/skip variant, a
brace in a rustdoc description) can hide a live mutation. A new unregistered
mutation, including a repo-write, fails the build.

The marker check (does each resolver body call its gate) is a best-effort static
backstop, bounded to each resolver's own comment-masked, string-aware body. It is
explicitly not the completeness guarantee and not load-bearingness: a marker inside
a string literal is a documented vacuity residual, and the adversarial sqlx tests in
graphql/mutation.rs are the real proof the gates reject.

Verified by execution: an unregistered mutation reddens (unclassified), a removed
did_matches gate reddens (missing marker), the clean tree is green; no DB, no
feature flag.
2026-08-28 23:16:03 -05:00
21 changed files with 2401 additions and 15160 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
name: "Gitlawb CodeQL config"
paths-ignore:
- "**/tests/**"
- "**/test_support/**"
-10
View File
@@ -116,16 +116,6 @@ jobs:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
# The real-node deny harness is behind the `test-harness` feature (so its
# spawn surface never compiles into the production binary), which means
# `--workspace` above skips it. Run it explicitly so the trust-boundary
# regression cases (INV-1/INV-2/INV-8) execute on every PR. Uses the same
# Postgres service; `#[sqlx::test]` provisions an isolated DB per test.
- name: cargo test (real-node deny harness)
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked -p gitlawb-node --features test-harness --lib --test deny_harness
build-release:
name: build --release
runs-on: ubuntu-latest
Generated
+12 -93
View File
@@ -770,23 +770,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ark-ff"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
dependencies = [
"ark-ff-asm 0.6.0",
"ark-ff-macros 0.6.0",
"ark-serialize 0.6.0",
"ark-std 0.6.0",
"digest 0.10.7",
"educe",
"num-bigint",
"num-traits",
"zeroize",
]
[[package]]
name = "ark-ff-asm"
version = "0.3.0"
@@ -817,16 +800,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-asm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.3.0"
@@ -865,19 +838,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
dependencies = [
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-serialize"
version = "0.3.0"
@@ -911,30 +871,6 @@ dependencies = [
"num-bigint",
]
[[package]]
name = "ark-serialize"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
dependencies = [
"ark-serialize-derive",
"ark-std 0.6.0",
"digest 0.10.7",
"num-bigint",
"serde_with",
]
[[package]]
name = "ark-serialize-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-std"
version = "0.3.0"
@@ -965,16 +901,6 @@ dependencies = [
"rand 0.8.6",
]
[[package]]
name = "ark-std"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
dependencies = [
"num-traits",
"rand 0.8.6",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -3374,22 +3300,19 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"anyhow",
"gitlawb-core",
"libc",
"mockito",
"reqwest",
"tempfile",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "gitlawb-attest"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3406,7 +3329,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"anyhow",
"base64",
@@ -3427,14 +3350,13 @@ dependencies = [
"sha2",
"thiserror 2.0.18",
"tokio",
"url",
"uuid",
"zeroize",
]
[[package]]
name = "gitlawb-node"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"alloy",
"anyhow",
@@ -3490,11 +3412,10 @@ dependencies = [
[[package]]
name = "gl"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"alloy",
"anyhow",
"base64",
"chrono",
"clap",
"dirs",
@@ -3543,9 +3464,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.18"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
@@ -3864,7 +3785,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -3900,7 +3821,6 @@ version = "0.4.0"
dependencies = [
"anyhow",
"gitlawb-core",
"mockito",
"reqwest",
"serde",
"serde_json",
@@ -5432,7 +5352,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5469,7 +5389,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
@@ -5819,15 +5739,14 @@ dependencies = [
[[package]]
name = "ruint"
version = "1.20.0"
version = "1.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
dependencies = [
"alloy-rlp",
"ark-ff 0.3.0",
"ark-ff 0.4.2",
"ark-ff 0.5.0",
"ark-ff 0.6.0",
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
-4
View File
@@ -26,15 +26,11 @@ COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
# gitlawb-node declares BOTH a [[bin]] and a [lib], so it needs both stubs:
# cargo resolves every declared target before it fetches anything, and a
# missing src/lib.rs aborts the layer with a target resolution error.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-node/src/lib.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
+1 -1
View File
@@ -71,7 +71,7 @@ Known limitations:
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID) but are limited to the agent-task queue; repo writes are not exposed over GraphQL and remain REST-only.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
See:
+2 -26
View File
@@ -6,28 +6,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lib]
name = "gitlawb_node"
path = "src/lib.rs"
[[bin]]
name = "gitlawb-node"
path = "src/main.rs"
# Exposes the `test_harness` spawn surface (src/test_harness.rs) to the
# real-node deny-harness integration crate. Off by default so the production
# binary never compiles test-only boot code. Enable with
# `cargo test -p gitlawb-node --features test-harness`.
[features]
test-harness = []
# Only builds when `test-harness` is enabled; otherwise skipped (not an error),
# so `cargo test --workspace` without the feature stays green.
[[test]]
name = "deny_harness"
path = "tests/deny_harness.rs"
required-features = ["test-harness"]
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
ed25519-dalek = { workspace = true }
@@ -95,12 +77,6 @@ libp2p-dns = { version = "0.44.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
tempfile = "3"
# test-util enables tokio::test(start_paused): the delayed claim re-sweep
# test drives the real CLAIM_SWEEP_MIN_AGE-plus-margin sleep on paused time.
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
# on which timer fired instead of on wall clock.
tokio = { workspace = true, features = ["test-util"] }
# Used by the deny-harness integration crate (tests/deny_harness.rs): the
# #[sqlx::test] macro for an ephemeral per-test pool, and reqwest as the real
# HTTP client that drives deny paths over the socket.
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "macros", "migrate"] }
reqwest = { workspace = true }
hex = "0.4"
+548
View File
@@ -279,6 +279,554 @@ mod authz_guard {
}
}
/// Completeness fence over the GraphQL mutation surface: the mirror of
/// `every_in_scope_mutation_has_its_gate` for `graphql/mutation.rs`. Kept a
/// SEPARATE guard with its own table (never folded into the REST rows above):
/// a guard over a heterogeneous set must classify per member, and these
/// same-named task rows belong to the GraphQL surface, not `api/tasks.rs`.
///
/// COMPLETENESS comes from the engine's own SDL (`schema.sdl()`), parsed as a
/// structured AST (#219), NOT introspection and NOT a text parse of source.
/// This matters: async-graphql OMITS `#[graphql(visible = false)]` fields from
/// introspection while leaving them executable, so an introspection-sourced fence
/// could be defeated by a hidden mutation. SDL includes those fields, so no
/// visibility flag (and, via the AST parse, no brace-in-description quirk) can hide
/// a live mutation. The SDL field set must equal the registered set: a new field with
/// no row fails (a repo-write cannot slip in unlisted); a registered name absent from
/// the schema fails (renamed or removed). The set is also cross-checked against
/// introspection, so any field present in SDL but hidden from introspection fails
/// named as a forbidden hidden mutation.
///
/// The MARKER check is the only source-scraped part, and it is not the
/// completeness guarantee. Three honest limits, so a green check is not misread
/// as full authorization coverage:
/// 1. A present marker is not load-bearingness (the gate may not redden on a
/// hostile probe: the #203 present-but-vacuous case). The adversarial tests
/// in `graphql/mutation.rs` are the load-bearing layer.
/// 2. A present marker is not gate-correctness (it may be the wrong bucket for
/// the operation's resource: a repo-write registered as signer-self would
/// pass, so INV-1 review remains the defense).
/// 3. The marker check scrapes the comment-masked, brace-bounded resolver body;
/// a `{`/`}` or a `did_matches(` inside a STRING literal is not masked, so it
/// could mis-slice that body or satisfy the marker vacuously (the same
/// vacuous-marker class as limit 1). Completeness is unaffected: it comes
/// from the SDL AST, not from this scrape.
#[test]
fn every_graphql_mutation_has_its_gate() {
// (Rust snake name, schema field in camelCase, expected gate marker). The
// four are Bucket C signer-self (`did_matches(`); a repo-write would be
// Bucket A (`require_repo_owner(`). Same-named REST rows live in the guard
// above against `api/tasks.rs`.
let registered: &[(&str, &str, &str)] = &[
("create_task", "createTask", "did_matches("),
("claim_task", "claimTask", "did_matches("),
("complete_task", "completeTask", "did_matches("),
("fail_task", "failTask", "did_matches("),
];
// Completeness + hidden-mutation cross-check against the REAL schema (#219),
// delegated to the generic `assert_mutation_fence` so the SDL sourcing and the
// cross-check are ALSO exercised by a synthetic hidden schema in
// `fence_rejects_a_hidden_mutation` (INV-21). Because the real MutationRoot has no
// hidden field today, that synthetic test — not a hypothetical production canary —
// is what turns reverting the SDL source or deleting the cross-check RED.
{
use crate::graphql::mutation::MutationRoot;
use crate::graphql::query::QueryRoot;
use crate::graphql::subscription::SubscriptionRoot;
let schema =
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
let registered_camel: Vec<&str> = registered.iter().map(|(_, c, _)| *c).collect();
assert_mutation_fence(&schema, &registered_camel);
}
// Marker present (source scrape; the vacuity limit, backstopped by the
// adversarial tests): each resolver body carries its bucket marker.
let masked = mask_comments(include_str!("../graphql/mutation.rs"));
for (snake, _, marker) in registered {
let body = resolver_body(&masked, snake);
assert!(
body.contains(marker),
"GraphQL mutation `{snake}` is missing its gate marker `{marker}`: gate removed"
);
}
}
/// Version-safety self-check (#219, INV-21): the fence's completeness guarantee rests
/// on `schema.sdl()` INCLUDING `visible = false` fields while introspection omits
/// them. That is observed async-graphql behavior, not a documented contract, so pin
/// it here: a synthetic schema with a hidden mutation must have that field in its
/// SDL-derived set and NOT in its introspection-derived set. If a future
/// async-graphql upgrade ever filters SDL by visibility, the first assertion flips
/// RED — the assumption is load-bearing, not trusted.
#[test]
fn sdl_source_sees_hidden_mutations_that_introspection_omits() {
use async_graphql::{EmptySubscription, Object, Schema};
struct Q;
#[Object]
impl Q {
async fn ping(&self) -> i32 {
0
}
}
struct M;
#[Object]
impl M {
async fn visible_mut(&self) -> i32 {
1
}
#[graphql(visible = false)]
async fn hidden_mut(&self) -> i32 {
2
}
}
let schema = Schema::build(Q, M, EmptySubscription).finish();
// SDL source (the fix): includes the hidden field.
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
assert!(
sdl_fields.iter().any(|f| f == "hiddenMut"),
"SDL-sourced set MUST include the visible=false mutation; if this is RED, \
async-graphql started filtering SDL by visibility and the fence's \
completeness guarantee is broken: {sdl_fields:?}"
);
// Introspection (the fence's own helper): OMITS the hidden field. This is the
// exact gap the fix closes — an introspection-sourced fence never sees `hiddenMut`.
let introspected = introspection_mutation_fields(&schema);
assert!(
!introspected.iter().any(|f| f == "hiddenMut"),
"introspection is expected to OMIT the visible=false mutation (that is the \
gap the SDL source closes); got {introspected:?}"
);
// The exact diff the fence computes: hidden ∈ (sdl \ introspection).
let hidden: Vec<&String> = sdl_fields
.iter()
.filter(|f| !introspected.iter().any(|i| i == *f))
.collect();
assert_eq!(
hidden,
vec!["hiddenMut"],
"the SDL-vs-introspection diff must surface exactly the hidden mutation"
);
}
/// INV-21 wiring guard (#219): drives the generic `assert_mutation_fence` — the SAME
/// helper the real fence calls — against a synthetic schema whose mutation is
/// `visible = false` and IS registered, so completeness passes and ONLY the
/// hidden-from-introspection cross-check can fail. This is what makes the fence's SDL
/// sourcing load-bearing without a production canary: reverting the source to
/// introspection (the hidden field then absent from the SDL set) or deleting the
/// cross-check removes this panic (or changes its message), turning the
/// `should_panic` test RED. The real MutationRoot has no hidden field, so nothing on
/// the real schema could catch that regression.
#[test]
#[should_panic(expected = "hidden from introspection")]
fn fence_rejects_a_hidden_mutation() {
use async_graphql::{EmptySubscription, Object, Schema};
struct Q;
#[Object]
impl Q {
async fn ping(&self) -> i32 {
0
}
}
struct M;
#[Object]
impl M {
async fn visible_mut(&self) -> i32 {
1
}
#[graphql(visible = false)]
async fn hidden_mut(&self) -> i32 {
2
}
}
let schema = Schema::build(Q, M, EmptySubscription).finish();
// Both fields registered, so completeness passes and only the cross-check fires.
assert_mutation_fence(&schema, &["visibleMut", "hiddenMut"]);
}
/// Deprecated-mutation guard (#219): a `#[graphql(deprecation = "...")]` mutation is
/// executable and present in SDL, but GraphQL introspection omits it by default. The
/// fence's introspection helper uses `includeDeprecated: true`, so a deprecated (but
/// visible) mutation is NOT mistaken for a hidden one. With it registered, the fence
/// must NOT panic. Remove `includeDeprecated: true` and this goes RED — the deprecated
/// field would look hidden and false-red as a forbidden visible=false mutation.
#[test]
fn fence_admits_a_deprecated_mutation() {
use async_graphql::{EmptySubscription, Object, Schema};
struct Q;
#[Object]
impl Q {
async fn ping(&self) -> i32 {
0
}
}
struct M;
#[Object]
impl M {
async fn visible_mut(&self) -> i32 {
1
}
#[graphql(deprecation = "use visibleMut")]
async fn deprecated_mut(&self) -> i32 {
2
}
}
let schema = Schema::build(Q, M, EmptySubscription).finish();
// Must not panic: the deprecated field is visible (present in introspection with
// includeDeprecated) and registered, so it is neither hidden nor unclassified.
assert_mutation_fence(&schema, &["visibleMut", "deprecatedMut"]);
}
/// Completeness + hidden-mutation cross-check for a BUILT schema (#219). Generic over
/// the schema type so the real fence AND the synthetic `fence_rejects_a_hidden_mutation`
/// / `fence_admits_a_deprecated_mutation` tests drive the SAME sourcing and cross-check.
/// Completeness is sourced from SDL (which includes `visible = false` fields); the
/// cross-check fails any SDL field hidden from introspection. This genericity is the
/// INV-21 fix: because the real MutationRoot has no hidden field, only a synthetic
/// hidden schema through THIS helper makes reverting the SDL source or removing the
/// cross-check RED, rather than leaving the class latent behind a production canary.
fn assert_mutation_fence<Q, M, S>(
schema: &async_graphql::Schema<Q, M, S>,
registered_camel: &[&str],
) where
Q: async_graphql::ObjectType + 'static,
M: async_graphql::ObjectType + 'static,
S: async_graphql::SubscriptionType + 'static,
{
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
let introspected = introspection_mutation_fields(schema);
// No SDL field may be hidden from introspection (a visible=false mutation).
for f in &sdl_fields {
assert!(
introspected.iter().any(|i| i == f),
"GraphQL mutation `{f}` is in the schema but hidden from introspection \
(a `#[graphql(visible = false)]` field): hidden mutations are forbidden \
by this fence — make it visible so its classification is enforceable, or \
remove it"
);
}
// Completeness both directions against the registered (camelCase) set.
for f in &sdl_fields {
assert!(
registered_camel.contains(&f.as_str()),
"unclassified GraphQL mutation `{f}`: add it to \
every_graphql_mutation_has_its_gate with a deliberate gate \
(`require_repo_owner(` for a repo-write, `did_matches(` for a task op)"
);
}
for c in registered_camel {
assert!(
sdl_fields.iter().any(|f| f == c),
"registered GraphQL mutation `{c}` is not in the schema \
(renamed or removed); update the table"
);
}
}
/// The mutation field names via the engine's introspection, run against a passed
/// schema. STRUCTURED (names arrive as data). Uses `fields(includeDeprecated: true)`:
/// GraphQL's default omits `@deprecated` fields, but a `#[graphql(deprecation = "...")]`
/// mutation is still executable and present in SDL, so without this the cross-check
/// above would false-red a deprecated mutation as a hidden (visible=false) one. Only a
/// real visibility filter should trip that arm; deprecation is a lifecycle marker.
fn introspection_mutation_fields<Q, M, S>(
schema: &async_graphql::Schema<Q, M, S>,
) -> Vec<String>
where
Q: async_graphql::ObjectType + 'static,
M: async_graphql::ObjectType + 'static,
S: async_graphql::SubscriptionType + 'static,
{
use async_graphql::Value;
let resp = tokio::runtime::Runtime::new()
.expect("tokio runtime")
.block_on(schema.execute(
"{ __schema { mutationType { fields(includeDeprecated: true) { name } } } }",
));
assert!(
resp.errors.is_empty(),
"schema introspection failed: {:?}",
resp.errors
);
let mut names = Vec::new();
if let Value::Object(root) = &resp.data {
if let Some(Value::Object(sc)) = root.get("__schema") {
if let Some(Value::Object(mt)) = sc.get("mutationType") {
if let Some(Value::List(field_list)) = mt.get("fields") {
for field in field_list {
if let Value::Object(fo) = field {
if let Some(Value::String(n)) = fo.get("name") {
names.push(n.clone());
}
}
}
}
}
}
}
names
}
/// The mutation field names sourced from the built schema's SDL — the completeness
/// source (#219). Builds the same DB-free schema as the introspection helper and runs
/// its `.sdl()` (sync, no runtime needed) through [`mutation_field_names_from_sdl`].
/// SDL includes `#[graphql(visible = false)]` fields, so this source cannot be
/// defeated by hiding a mutation from introspection.
fn graphql_mutation_fields_from_sdl() -> Vec<String> {
use crate::graphql::mutation::MutationRoot;
use crate::graphql::query::QueryRoot;
use crate::graphql::subscription::SubscriptionRoot;
let schema =
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
mutation_field_names_from_sdl(&schema.sdl())
}
/// The mutation type's field names extracted from an async-graphql SDL string via
/// the engine's OWN parser AST (`async_graphql::parser::parse_schema`), NOT
/// introspection. This is the completeness source (#219): unlike introspection,
/// which async-graphql filters by `visible` (a `#[graphql(visible = false)]`
/// mutation is executable but ABSENT from introspection), `schema.sdl()` includes
/// every registered field, so a hidden mutation still appears here and must carry a
/// classified row. Parsing the AST (not a brace/line text scan) means a `}` inside a
/// field description cannot mis-slice the type block. camelCase, as async-graphql
/// emits. An empty result on a navigation miss is safe: the set-equality in
/// `every_graphql_mutation_has_its_gate` turns "no fields" into a loud failure for
/// every registered name, never a vacuous pass.
fn mutation_field_names_from_sdl(sdl: &str) -> Vec<String> {
use async_graphql::parser::parse_schema;
use async_graphql::parser::types::{TypeKind, TypeSystemDefinition};
let doc = parse_schema(sdl).expect("async-graphql SDL must parse");
// The mutation root type name: from the `schema { mutation: X }` block if
// present, else the GraphQL default "Mutation".
let mutation_type = doc
.definitions
.iter()
.find_map(|def| match def {
TypeSystemDefinition::Schema(s) => s
.node
.mutation
.as_ref()
.map(|m| m.node.as_str().to_string()),
_ => None,
})
.unwrap_or_else(|| "Mutation".to_string());
// The fields of that object type.
doc.definitions
.iter()
.find_map(|def| match def {
TypeSystemDefinition::Type(t) if t.node.name.node.as_str() == mutation_type => {
match &t.node.kind {
TypeKind::Object(obj) => Some(
obj.fields
.iter()
.map(|f| f.node.name.node.as_str().to_string())
.collect::<Vec<_>>(),
),
_ => None,
}
}
_ => None,
})
.unwrap_or_default()
}
#[test]
fn sdl_extractor_includes_a_hidden_visible_false_field() {
// A field that would be hidden from introspection must still be listed from SDL.
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
type QueryRoot { ping: Int! }\n\
type MutationRoot { createTask: Int! hiddenMut: Int! }\n";
let fields = mutation_field_names_from_sdl(sdl);
assert!(
fields.iter().any(|f| f == "createTask"),
"visible field must be listed: {fields:?}"
);
assert!(
fields.iter().any(|f| f == "hiddenMut"),
"a visible=false field (present in SDL, hidden from introspection) MUST be \
listed by the completeness source: {fields:?}"
);
}
#[test]
fn sdl_extractor_is_not_fooled_by_a_brace_in_a_field_description() {
// A `}` inside a field description must not truncate the type block (the AST
// parse is immune to the text-parse quirk the old design feared).
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
type QueryRoot { ping: Int! }\n\
type MutationRoot {\n\
\"\"\"a description with a } brace and { another\"\"\"\n\
createTask: Int!\n\
claimTask: Int!\n\
}\n";
let fields = mutation_field_names_from_sdl(sdl);
assert!(
fields.iter().any(|f| f == "createTask") && fields.iter().any(|f| f == "claimTask"),
"both fields must be extracted despite a brace in a description: {fields:?}"
);
}
#[test]
fn sdl_extractor_returns_empty_for_a_query_only_schema() {
let sdl = "schema { query: QueryRoot }\ntype QueryRoot { ping: Int! }\n";
assert!(
mutation_field_names_from_sdl(sdl).is_empty(),
"a schema with no mutation root yields no mutation fields (no panic)"
);
}
/// The comment-masked body of `async fn <snake>` in `graphql/mutation.rs`, bounded
/// to the resolver's OWN braces (its `{ .. }`), for the marker check. Brace-matching
/// on the comment-masked source means a `}` in a trailing helper cannot extend the
/// body and a comment marker does not count. A `{`/`}` inside a STRING literal is
/// not masked, a documented marker-check-only residual (limit 3); completeness does
/// not use this.
fn resolver_body(masked_src: &str, snake: &str) -> String {
let needle = format!("async fn {snake}(");
let start = masked_src.find(&needle).unwrap_or_else(|| {
panic!("resolver `async fn {snake}(` not found in graphql/mutation.rs")
});
let open = start
+ masked_src[start..]
.find('{')
.expect("resolver body opening brace");
let mut depth = 0i32;
let mut end = masked_src.len();
for (i, c) in masked_src[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i + 1;
break;
}
}
_ => {}
}
}
masked_src[open..end].to_string()
}
/// `src` with `//` line comments and `/* */` block comments (nested) blanked to
/// spaces, WITHOUT opening a comment inside a `"..."` string (so a `"https://"` does
/// not erase the rest of its line). String CONTENTS are left intact, so a brace or
/// a gate marker inside a string is not masked; for the marker check that is a
/// documented vacuity residual (limit 3), and completeness does not use this at
/// all. Char literals and raw/byte strings are not tracked (absent in resolver
/// bodies); a `'"'` char or a raw string is the remaining narrow residual.
fn mask_comments(src: &str) -> String {
let chars: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut i = 0;
let mut block_depth = 0usize;
let mut in_string = false;
while i < chars.len() {
let c = chars[i];
let next = chars.get(i + 1).copied();
if block_depth > 0 {
if c == '/' && next == Some('*') {
block_depth += 1;
out.push_str(" ");
i += 2;
} else if c == '*' && next == Some('/') {
block_depth -= 1;
out.push_str(" ");
i += 2;
} else {
out.push(if c == '\n' { '\n' } else { ' ' });
i += 1;
}
} else if in_string {
out.push(c);
if c == '\\' {
if let Some(n) = next {
out.push(n);
i += 2;
} else {
i += 1;
}
} else {
if c == '"' {
in_string = false;
}
i += 1;
}
} else if c == '"' {
in_string = true;
out.push('"');
i += 1;
} else if c == '/' && next == Some('/') {
while i < chars.len() && chars[i] != '\n' {
out.push(' ');
i += 1;
}
} else if c == '/' && next == Some('*') {
block_depth = 1;
out.push_str(" ");
i += 2;
} else {
out.push(c);
i += 1;
}
}
out
}
/// The SDL field set (the completeness source of truth, #219) is exactly the
/// registered mutations. If async-graphql adds or renames a field, this reddens
/// and forces the `every_graphql_mutation_has_its_gate` table to be updated. Uses
/// SDL, not introspection: introspection omits `visible = false` fields, so an
/// introspection-based pin here would go green while a hidden mutation existed.
#[test]
fn schema_lists_exactly_the_registered_mutations() {
let mut fields = graphql_mutation_fields_from_sdl();
fields.sort();
assert_eq!(
fields,
vec!["claimTask", "completeTask", "createTask", "failTask"],
"the SDL mutation field set is the authoritative discovered set"
);
}
/// The marker check is bounded to a resolver's OWN body and is comment- and
/// string-aware: a `//` inside a `"https://"` string does not erase a real gate on
/// that line (no false-red), and a trailing module helper's `did_matches(` does not
/// rescue a resolver whose own gate was removed (no false-green).
#[test]
fn resolver_body_is_bounded_and_comment_and_string_aware() {
let src = "\
impl MutationRoot {
async fn ok(&self) -> Result<u32> {
let _ = (\"see https://x/y\", did_matches(a, b)); // URL string then gate, one line
Ok(0)
}
async fn last(&self) -> Result<u32> {
// did_matches( only in a comment
Ok(0)
}
}
fn helper() { let _ = did_matches(z, z); }
#[cfg(test)]
mod tests { async fn t() { let _ = did_matches(x, y); } }
";
let masked = mask_comments(src);
assert!(
resolver_body(&masked, "ok").contains("did_matches("),
"a `//` inside a string must not erase the real gate on that line"
);
assert!(
!resolver_body(&masked, "last").contains("did_matches("),
"the marker check must be bounded to the resolver's own body (a trailing \
helper's marker must not rescue a resolver whose gate was removed)"
);
}
/// Proves the comment-stripping that GUARD-1 added: a marker that appears only
/// in a full-line comment (the real `replicas.rs` false-pass shape) must NOT
/// satisfy a row.
+2 -4
View File
@@ -205,10 +205,8 @@ pub struct Config {
pub metrics_addr: String,
/// Maximum time to wait for in-flight requests to drain on shutdown, in
/// seconds. On the shutdown signal the server stops accepting new
/// connections and gives in-flight requests this long to complete; at
/// expiry it abandons whatever remains (clients observe a closed
/// connection, not an error response) and exits. Default: 30s.
/// seconds. After this elapses, the server returns 503 to anything still
/// in flight and exits. Default: 30s.
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
pub shutdown_grace_secs: u64,
+5 -5
View File
@@ -270,7 +270,7 @@ impl Db {
&self.pool
}
#[cfg(any(test, feature = "test-harness"))]
#[cfg(test)]
pub fn for_testing(pool: PgPool) -> Self {
Self { pool }
}
@@ -279,7 +279,7 @@ impl Db {
/// provisions an empty per-test database, so DB-backed tests must run this
/// before seeding. Reuses the production `migrate()` path (the advisory lock
/// is harmless on an isolated test DB and migrations are idempotent).
#[cfg(any(test, feature = "test-harness"))]
#[cfg(test)]
pub(crate) async fn run_migrations(&self) -> Result<()> {
self.migrate().await
}
@@ -8278,8 +8278,8 @@ mod peer_authority_tests {
/// | `prune_non_public_peers` (db/mod.rs) | a delete keyed on a computed bad-DID array; cannot repoint; boot-only caller in main.rs |
/// | `seed_local_peer` (sync.rs) | excluded by test-module location: a deliberate `upsert_peer` bypass for `file://` fixtures, which the public-URL gate rejects |
/// | `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 |
/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (lib.rs) | test-only fixture seed for the gossip hysteresis round; the row must pre-exist so the assertion is about when `mark_peer_ping` fires |
///
/// 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
@@ -8366,11 +8366,11 @@ mod peers_table_writer_guard {
const LEDGER: &[(&str, usize)] = &[
("a_legacy_row_can_still_refresh_its_liveness", 1),
(
"manual_ping_uses_readiness_without_mutating_federation_gate",
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
1,
),
(
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
"manual_ping_uses_readiness_without_mutating_federation_gate",
1,
),
("mark_peer_ping", 1),
+1 -1
View File
@@ -71,7 +71,7 @@ impl RepoStore {
/// Derives its own lock pool from `pool`, so callers that only have the main
/// pool (tests, `for_testing` sites in other modules) still get the
/// `after_release` semantics `acquire_write` depends on.
#[cfg(any(test, feature = "test-harness"))]
#[cfg(test)]
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
Self::new(
repos_dir,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-586
View File
@@ -1,586 +0,0 @@
//! Test-only spawn surface for the real-node deny harness (see
//! `tests/deny_harness.rs`). Feature-gated behind `test-harness` so it never
//! compiles into the production binary. It exposes a single boot constructor so
//! the out-of-crate integration test can bring up a real node over a bound TCP
//! socket without the integration crate needing access to `graphql`,
//! `rate_limit`, `repo_store`, or the other internals `AppState` is built from.
//!
//! The node is built the same way `test_support::build_state` builds it (p2p
//! disabled, real migrated pool), but bound on `127.0.0.1:0` and served through
//! the real `axum::serve` stack with connect-info, so the middleware order,
//! body limits, and per-IP rate limiters all run exactly as in production.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use clap::Parser;
use sqlx::PgPool;
use tokio::net::TcpListener;
use tokio::sync::watch;
use uuid::Uuid;
use gitlawb_core::identity::Keypair;
use crate::config::Config;
use crate::db::{Db, PullRequest, RepoRecord, VisibilityMode};
use crate::rate_limit::{RateLimiter, TrustedProxy};
use crate::state::AppState;
/// A running node bound to an ephemeral port. End every test with
/// [`TestNode::shutdown`]: it joins the serve task and closes the pool, so
/// `#[sqlx::test]`'s `DROP DATABASE` cleanup (which runs the moment the test
/// future returns) never races the server's still-open connections. Dropping
/// without it signals shutdown, aborts any remaining serve task, and removes
/// the temp repository directory, a fallback for tests that return or panic
/// before reaching teardown.
pub struct TestNode {
/// Base URL of the running node, e.g. `http://127.0.0.1:54321`.
pub base_url: String,
/// The node's own DID (for building requests that reference it).
pub node_did: String,
shutdown_tx: watch::Sender<bool>,
repos_dir: PathBuf,
/// Seeding handle over the same pool the node serves from. Kept private so
/// the integration crate seeds through the methods below rather than
/// naming `Db`/`RepoRecord` directly.
db: Arc<Db>,
/// The detached `axum::serve` task, joined by [`Self::shutdown`]. `None`
/// once taken, so the `Drop` fallback never overlaps a completed teardown.
/// Carries `serve`'s own `io::Result<()>` so `shutdown()` can observe it
/// rather than discard it (see the coverage note there).
server: Option<tokio::task::JoinHandle<std::io::Result<()>>>,
/// A clone of the served pool, kept so [`Self::shutdown`] can close it
/// (sqlx pool clones share one inner, so closing this closes the clones
/// inside `AppState`/`Db` too).
pool: PgPool,
/// Test-only: when set, `Drop` skips the graceful watch signal so ONLY
/// `handle.abort()` tears the serve task down. Defaults false, so every
/// other test keeps both redundant teardown mechanisms. Set via
/// [`Self::force_abort_only_teardown`] to fence that the abort alone
/// releases the database when the graceful chain is broken.
suppress_graceful_shutdown_on_drop: bool,
}
impl Drop for TestNode {
fn drop(&mut self) {
// Fallback for tests that return or panic before reaching
// [`TestNode::shutdown`]: flip the shared shutdown signal so the serve
// task exits gracefully, abort whatever is left of it, and remove the
// temp repos dir. Drop cannot await, so it cannot join the server or
// close the pool; a test that already failed should not panic again in
// teardown. All three actions are idempotent or no-ops after
// `shutdown()` (which takes the handle), so running afterwards is
// harmless.
//
// The abort matters when the graceful chain does not run to completion
// before sqlx's `DROP DATABASE` cleanup: `JoinHandle::abort()` is a
// synchronous cancellation request callable from non-async Drop, and
// the aborted task's future (with the `PgPool` clones inside its
// router/state) is dropped on a subsequent scheduler tick, which sqlx
// cleanup's own awaits provide before the drop statement runs. The
// race is closed by those scheduling points, not by the abort alone.
//
// `suppress_graceful_shutdown_on_drop` (test-only) forces the abort to
// carry teardown by itself, so a test can fence that the abort line is
// load-bearing; it is false everywhere else.
if !self.suppress_graceful_shutdown_on_drop {
let _ = self.shutdown_tx.send(true);
}
if let Some(handle) = self.server.take() {
handle.abort();
}
let _ = std::fs::remove_dir_all(&self.repos_dir);
}
}
/// Outcome of joining the serve task during teardown, split so the wedged-task
/// path is observable to a test (which injects a short timeout) without waiting
/// the production teardown window.
enum ServeTeardown {
/// The task returned (carrying `serve`'s `io::Result<()>`) — either within
/// the timeout, or in the race between the timeout firing and the abort
/// landing. A task that actually finished is never a wedge.
Graceful(std::io::Result<()>),
/// The task did not finish within the timeout and was aborted (cancelled)
/// and reaped.
TimedOutAborted,
}
/// Join `handle` within `timeout`; on expiry `abort()` it and await the aborted
/// handle so the serve task (and the `PgPool` clones inside its router/state) is
/// reaped BEFORE the caller returns and `#[sqlx::test]` runs `DROP DATABASE`.
/// Takes `&mut` (a `JoinHandle` is `Unpin`) so ownership is retained across the
/// timeout and the handle can be aborted on expiry — the previous code moved the
/// handle into `timeout` and, on elapse, dropped it, which DETACHES the still
/// running task rather than aborting it, leaking the test database (#194 F2).
async fn join_or_abort(
handle: &mut tokio::task::JoinHandle<std::io::Result<()>>,
timeout: Duration,
) -> ServeTeardown {
match tokio::time::timeout(timeout, &mut *handle).await {
Ok(join_result) => ServeTeardown::Graceful(join_result.expect("serve task must not panic")),
Err(_elapsed) => {
handle.abort();
match handle.await {
// The task finished in the race between the timeout firing and
// the abort landing — honor its result, not a false wedge.
Ok(result) => ServeTeardown::Graceful(result),
Err(e) if e.is_cancelled() => ServeTeardown::TimedOutAborted,
// A genuine panic in the serve task surfaces here, mirroring the
// in-time arm's `.expect("serve task must not panic")`.
Err(e) => std::panic::resume_unwind(e.into_panic()),
}
}
}
}
/// Allocate a process-unique temp directory for a spawned node's repositories
/// without pulling in the `tempfile` dev-dependency (which is unavailable to
/// the library crate under `--features test-harness`).
fn unique_repos_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("gitlawb-deny-harness-{}-{}", std::process::id(), n))
}
/// Build an [`AppState`] over the given migrated pool, mirroring
/// `test_support::build_state` but with a real on-disk repository directory so
/// git smart-HTTP routes can serve. P2P is always disabled.
fn build_state(db: Arc<crate::db::Db>, pool: PgPool, repos_dir: PathBuf) -> AppState {
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let node_did = keypair.did();
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let schema = Arc::new(crate::graphql::build_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
));
AppState {
config: Arc::new(Config::parse_from(["gitlawb-node"])),
db,
node_did,
node_keypair: Arc::new(keypair),
p2p: None,
http_client: Arc::new(reqwest::Client::new()),
ref_update_tx: ref_tx,
task_event_tx: task_tx,
graphql_schema: schema,
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(repos_dir, pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
ipfs_max_legacy_scan_rows: crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
shutdown_tx: watch::channel(false).0,
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
encrypt_inflight: crate::state::EncryptInflight::new(),
repo_write_leases: crate::state::RepoWriteLeases::new(8),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(
8,
),
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(
16,
),
git_bin: "git".to_string(),
}
}
/// Spawn a real node bound to `127.0.0.1:0` over the given (already-created,
/// empty) test pool. Runs the schema migrations, builds the router, and serves
/// it on a background task through the production `axum::serve` stack with
/// connect-info so per-IP layers key on the real peer. Returns once the socket
/// is bound and accepting connections.
pub async fn spawn_node(pool: PgPool) -> TestNode {
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
db.run_migrations()
.await
.expect("test schema migrations should apply");
let repos_dir = unique_repos_dir();
std::fs::create_dir_all(&repos_dir).expect("create temp repos dir");
let state = build_state(db.clone(), pool.clone(), repos_dir.clone());
let node_did = state.node_did.to_string();
let shutdown_tx = state.shutdown_tx.clone();
let mut shutdown_rx = shutdown_tx.subscribe();
let router = crate::server::build_router(state);
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind ephemeral port");
let addr = listener.local_addr().expect("read bound addr");
let server = tokio::spawn(async move {
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.changed().await;
})
.await
});
TestNode {
base_url: format!("http://{addr}"),
node_did,
shutdown_tx,
repos_dir,
db,
server: Some(server),
pool,
suppress_graceful_shutdown_on_drop: false,
}
}
impl TestNode {
/// Test-only: make `Drop` tear the serve task down through `handle.abort()`
/// alone by suppressing the graceful watch signal. Used to prove the abort
/// is load-bearing (that the database is released even when the graceful
/// chain is broken); no production or normal-teardown path calls this.
pub fn force_abort_only_teardown(&mut self) {
self.suppress_graceful_shutdown_on_drop = true;
}
/// Graceful async teardown: signal shutdown, join the serve task, close
/// the pool, remove the temp repos dir. Call this at the end of every
/// test. `#[sqlx::test]` issues `DROP DATABASE` as soon as the test
/// future returns, so the server (which owns pool clones inside
/// `AppState`/`Db`) must be gone and the pool closed before then, or the
/// cleanup fails and leaks per-test databases. A serve task that does not
/// exit within 10s panics loudly: a wedged graceful shutdown must fail
/// the test, not hang CI.
///
/// When `self` drops on return, `Drop` re-sends the signal and re-removes
/// the dir; both are idempotent, and the serve handle was already taken,
/// so the overlap is harmless.
pub async fn shutdown(self) {
self.shutdown_with_timeout(Duration::from_secs(10)).await;
}
/// Body of [`Self::shutdown`] with an injectable teardown timeout so a test
/// can drive the wedged-shutdown path without waiting the production 10s.
/// Production always calls this with 10s.
async fn shutdown_with_timeout(mut self, timeout: Duration) {
let _ = self.shutdown_tx.send(true);
let outcome = match self.server.take() {
Some(mut handle) => Some(join_or_abort(&mut handle, timeout).await),
None => None,
};
// Release the pool and temp dir on EVERY path before returning OR
// panicking. A wedged graceful shutdown is exactly when in-flight
// per-connection tasks still hold `PgPool` clones; aborting the serve
// future alone does not reliably drop them, so the explicit
// `pool.close()` (which closes the shared inner) is what frees them
// before `#[sqlx::test]` runs `DROP DATABASE`. Panicking before this
// (the previous behavior) skipped the close on the one path that needs
// it most (#194 F2).
self.pool.close().await;
let _ = std::fs::remove_dir_all(&self.repos_dir);
match outcome {
// Honest coverage contract: on axum 0.8.8,
// `WithGracefulShutdown::into_future` always resolves `Ok(())` (the
// shutdown signal is treated as success and `accept()` errors are
// retried forever rather than returned), so this `Err` arm is
// unreachable today - every existing harness test exercises only the
// `Ok` path here. Forward-compat insurance for a future axum where
// `serve` can fail: that should surface as a clear panic, not be
// silently discarded.
Some(ServeTeardown::Graceful(result)) => {
result.expect("axum::serve exited with an error");
}
Some(ServeTeardown::TimedOutAborted) => {
panic!(
"serve task did not exit within {timeout:?} of the shutdown \
signal (wedged graceful shutdown)"
);
}
None => {}
}
}
/// Insert a repo owned by `owner_did` and return its repo id. Mirrors
/// `test_support::seed_repo` (the `disk_path` field is unused by the
/// integration path — `RepoStore` computes the on-disk path from
/// `repos_dir`/owner/name, see [`Self::seed_bare_repo`]).
pub async fn seed_repo(&self, owner_did: &str, name: &str, is_public: bool) -> String {
let now = Utc::now();
let record = RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
};
self.db.create_repo(&record).await.expect("seed repo");
record.id
}
/// Mark `branch` as protected on `repo_id`, so a non-owner push to it is
/// rejected by the branch-protection gate (used by the deny-prober's
/// protected-push probe — #195, F3).
pub async fn seed_protected_branch(&self, repo_id: &str, branch: &str, created_by: &str) {
self.db
.protect_branch(repo_id, branch, created_by)
.await
.expect("seed protected branch");
}
/// Seed an open PR `number` in `repo_id`, authored by `author_did`. The
/// author-or-owner close gate (`close_pr`) loads the PR *before* it runs, so a
/// deny-prober row for `.../pulls/{number}/close` needs the PR to exist or a
/// stranger gets a 404 (absent entity) instead of the 403 the gate emits.
pub async fn seed_pr(&self, repo_id: &str, number: i64, author_did: &str) {
let now = Utc::now().to_rfc3339();
let pr = PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
number,
title: "seed pr".to_string(),
body: None,
author_did: author_did.to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
};
self.db.create_pr(&pr).await.expect("seed pr");
}
/// Seed issue `issue_id` (a git-ref JSON blob authored by `author_did`) in the
/// repo's bare on-disk store. Like [`Self::seed_pr`], the `close_issue`
/// owner-or-author gate loads the issue before it runs, so the deny-prober row
/// for `.../issues/{id}/close` needs the issue present to reach the 403.
pub fn seed_issue(&self, owner_did: &str, name: &str, issue_id: &str, author_did: &str) {
let slug = owner_did.replace([':', '/'], "_");
let bare = self.repos_dir.join(&slug).join(format!("{name}.git"));
let json = format!(r#"{{"author":"{author_did}"}}"#);
crate::git::issues::create_issue(&bare, issue_id, &json).expect("seed issue");
}
/// Add a path-scoped visibility rule restricting `path_glob` to
/// `reader_dids` (empty = only the owner). Mode B keeps object SHAs intact
/// and withholds blob content, which is what the read/replication gates
/// enforce.
pub async fn withhold_path(
&self,
repo_id: &str,
path_glob: &str,
reader_dids: &[String],
created_by: &str,
) {
self.db
.set_visibility_rule(
repo_id,
path_glob,
VisibilityMode::B,
reader_dids,
created_by,
)
.await
.expect("set visibility rule");
}
/// Record `pinned_cids` rows for every blob OID returned by
/// [`Self::seed_bare_repo`] so `/ipfs/{cid}` can resolve via provenance on
/// current main (the table is the primary lookup path). CIDs are computed
/// from raw object bytes exactly as the production pin path does
/// (`Cid::from_git_object_bytes`), not from the git oid hash.
/// Returns each seeded path's canonical CID for use in `GET /ipfs/{cid}`.
pub async fn seed_pinned_cids_for_repo(
&self,
owner_did: &str,
repo_name: &str,
repo_id: &str,
oids: &std::collections::HashMap<String, String>,
) -> std::collections::HashMap<String, String> {
let slug = owner_did.replace([':', '/'], "_");
let bare = self.repos_dir.join(&slug).join(format!("{repo_name}.git"));
let mut cids = std::collections::HashMap::new();
for (path, oid_hex) in oids {
if path == "HEAD" {
continue;
}
let (_ty, raw) = crate::git::store::read_object(&bare, oid_hex)
.expect("read seeded object")
.expect("seeded object exists in bare repo");
let preliminary = gitlawb_core::cid::Cid::from_git_object_bytes(&raw).to_string();
let cid = preliminary
.parse::<cid::CidGeneric<64>>()
.expect("parse production pin cid")
.to_string();
self.db
.record_pinned_cid_with_source(oid_hex, &cid, repo_id)
.await
.expect("seed pinned_cids row");
let roundtrip = self
.db
.oids_for_cid(&cid)
.await
.expect("oids_for_cid after seed");
assert!(
!roundtrip.is_empty(),
"pinned_cids seed for {path} did not round-trip cid {cid}"
);
cids.insert(path.clone(), cid);
}
cids
}
/// Create a real bare git repository on disk at the exact path
/// `RepoStore::acquire` reads (`<repos_dir>/<owner-slug>/<name>.git`), with
/// one commit on `main` containing `files` (`(path, contents)`). Returns the
/// blob OID of each path so callers can assert a withheld OID never appears
/// in served bytes (U8). Shells out to `git`, mirroring
/// `test_support`'s served-content seam.
/// `object_format` is `"sha1"` for the git smart-HTTP surfaces and
/// `"sha256"` for the content-addressed `/ipfs/{cid}` surface (whose CIDs
/// are the sha2-256 object ids).
pub fn seed_bare_repo(
&self,
owner_did: &str,
name: &str,
files: &[(&str, &str)],
object_format: &str,
) -> std::collections::HashMap<String, String> {
let run = |args: &[&str], cwd: &std::path::Path| {
// Test-harness-only fixture seeding, feature-gated out of the production
// binary; runs local git against a tempdir inside a test's own lifetime.
// allow-unbounded-git: test-only seeding, never on a request path holding a permit
let out = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output() // allow-unbounded-git: test-harness fixture seeding only
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
// Build a source work tree, then bare-clone it into the served path.
let src = self.repos_dir.join(format!("src-{name}"));
std::fs::create_dir_all(&src).expect("create src dir");
let fmt_arg = format!("--object-format={object_format}");
run(&["init", "-q", "-b", "main", &fmt_arg], &src);
run(&["config", "user.email", "t@t"], &src);
run(&["config", "user.name", "t"], &src);
for (path, contents) in files {
let full = src.join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("create file parent dir");
}
std::fs::write(&full, contents).expect("write seed file");
run(&["add", path], &src);
}
run(&["commit", "-q", "-m", "seed"], &src);
let mut oids = std::collections::HashMap::new();
for (path, _) in files {
let oid = run(&["rev-parse", &format!("HEAD:{path}")], &src);
oids.insert((*path).to_string(), oid);
}
// The HEAD commit oid, used as the `want` when driving upload-pack.
oids.insert("HEAD".to_string(), run(&["rev-parse", "HEAD"], &src));
let slug = owner_did.replace([':', '/'], "_");
let bare = self.repos_dir.join(&slug).join(format!("{name}.git"));
std::fs::create_dir_all(bare.parent().unwrap()).expect("create bare parent");
run(
&[
"clone",
"--bare",
"-q",
src.to_str().unwrap(),
bare.to_str().unwrap(),
],
&self.repos_dir,
);
oids
}
}
#[cfg(test)]
mod tests {
use super::{join_or_abort, ServeTeardown};
use std::time::Duration;
/// A wedged serve task (never returns within the window) must be ABORTED and
/// reaped on timeout, not detached. The previous teardown moved the
/// `JoinHandle` into `timeout` and dropped it on elapse, which detaches the
/// still-running task and leaks the test DB across `DROP DATABASE` (#194 F2).
/// Load-bearing: neutralize the abort in `join_or_abort` (return without
/// abort+await) and this flips RED — `handle.is_finished()` stays false
/// because the task was left running.
#[tokio::test]
async fn join_or_abort_aborts_a_wedged_task_on_timeout() {
let mut handle: tokio::task::JoinHandle<std::io::Result<()>> = tokio::spawn(async {
loop {
tokio::time::sleep(Duration::from_secs(3600)).await;
}
});
assert!(
matches!(
join_or_abort(&mut handle, Duration::from_millis(50)).await,
ServeTeardown::TimedOutAborted
),
"a task that never returns must be reported timed-out, not graceful"
);
assert!(
handle.is_finished(),
"the wedged serve task must be aborted and reaped, not detached"
);
}
/// A task that returns within the window is reported graceful, carrying its
/// `io::Result` — the normal teardown path.
#[tokio::test]
async fn join_or_abort_reports_graceful_when_task_exits_in_time() {
let mut handle: tokio::task::JoinHandle<std::io::Result<()>> =
tokio::spawn(async { Ok(()) });
assert!(
matches!(
join_or_abort(&mut handle, Duration::from_secs(5)).await,
ServeTeardown::Graceful(Ok(()))
),
"a prompt clean exit must be reported graceful"
);
}
}
File diff suppressed because it is too large. Load diff
-237
View File
@@ -1,237 +0,0 @@
//! INV-8 deny assertion. A denial must be an actual refusal (4xx) whose body
//! and headers carry none of the data it is withholding, and must never be a 2xx
//! (an empty-200 rendered as success is the denial-as-success bug this guards).
//!
//! The check is split into a pure `check_denied` (unit-tested below with the
//! self-check scenarios) and an async `assert_denied` wrapper that reads a real
//! `reqwest::Response`.
/// Pure deny check. Returns `Err(reason)` when the response is not a clean
/// denial. `withheld` are exact tokens (OIDs, paths, private slugs) that must
/// not appear anywhere in the body; register short-OID prefixes and encoded
/// path forms as separate tokens at the call site (a raw substring scan cannot
/// normalize them).
pub fn check_denied(
status: u16,
body: &str,
expected: u16,
withheld: &[&str],
) -> Result<(), String> {
// The harness only ever expects denials; a non-4xx expectation is a
// programming error in the test, not a node behavior under test.
if !(400..500).contains(&expected) {
return Err(format!(
"test bug: assert_denied expected status {expected} is not a 4xx"
));
}
// INV-8: a denial rendered as success is the headline bug. Catch it
// explicitly so the failure message names it, rather than only failing the
// status-equality check below.
if (200..300).contains(&status) {
return Err(format!(
"denial rendered as success: got {status}, expected denial {expected}. \
body={body:?}"
));
}
if status != expected {
return Err(format!(
"expected denial status {expected}, got {status}. body={body:?}"
));
}
for token in withheld {
if token.is_empty() {
continue;
}
if body.contains(token) {
return Err(format!(
"withheld token {token:?} leaked in denial body: {body:?}"
));
}
}
Ok(())
}
/// Like [`check_denied`] but also scans serialized response headers for withheld
/// tokens. Call sites that only have a body should keep using `check_denied`.
pub fn check_denied_with_headers(
status: u16,
body: &str,
header_text: &str,
expected: u16,
withheld: &[&str],
) -> Result<(), String> {
if let Err(e) = check_denied(status, body, expected, withheld) {
return Err(e);
}
for token in withheld {
if token.is_empty() {
continue;
}
if header_text.contains(token) {
return Err(format!(
"withheld token {token:?} leaked in denial headers: {header_text:?}"
));
}
}
Ok(())
}
/// Replace each withheld token in `s` with `[REDACTED]` so a test-failure
/// witness does not itself leak the secret it caught.
fn redact_withheld(s: &str, withheld: &[&str]) -> String {
let mut result = s.to_string();
for token in withheld {
if !token.is_empty() {
result = result.replace(token, "[REDACTED]");
}
}
result
}
/// Drive a real response through the deny check and panic on failure. Reads the
/// full body once.
pub async fn assert_denied(resp: reqwest::Response, expected: u16, withheld: &[&str]) {
let status = resp.status().as_u16();
let header_text = resp
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|v| format!("{}: {}", name.as_str(), v))
})
.collect::<Vec<_>>()
.join("\n");
// A body-read failure must NOT fold to "" — an empty body passes the withheld
// scan vacuously, so an unreadable denial (a mid-body reset/truncation) would be
// certified leak-free without ever inspecting the bytes. Fail loud instead. A
// legitimately empty body is `Ok("")`, not `Err`, so the honest empty-401 denials
// still pass.
let body = resp
.text()
.await
.expect("read denial body: an unreadable denial cannot be certified leak-free");
if let Err(reason) = check_denied_with_headers(status, &body, &header_text, expected, withheld)
{
// Panic with a fixed string: CodeQL's taint analysis traces withheld
// tokens from check_denied through redact_withheld into the panic sink,
// even though redact_withheld strips them at runtime. A fixed message
// keeps no tainted data on the sink path. The test name identifies
// which probe failed; check_denied's unit tests cover the error detail.
let _ = redact_withheld(&reason, withheld);
panic!("deny assertion failed: status/body/header leak check");
}
}
#[cfg(test)]
mod tests {
use super::{check_denied, check_denied_with_headers, redact_withheld};
#[test]
fn clean_403_with_no_leak_passes() {
let r = check_denied(
403,
r#"{"error":"only the repo owner can do this"}"#,
403,
&["a1b2c3"],
);
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn empty_200_is_flagged_as_denial_rendered_as_success() {
let r = check_denied(200, "", 403, &[]);
assert!(r.is_err());
assert!(r.unwrap_err().contains("rendered as success"));
}
#[test]
fn leaking_403_fails_and_names_the_token() {
let withheld = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let body = format!("not found near object {withheld}");
let r = check_denied(403, &body, 403, &[withheld]);
assert!(r.is_err());
assert!(r.unwrap_err().contains(withheld));
}
#[test]
fn wrong_status_fails() {
// A 404 when the owner gate's 403 was expected must not pass: it means
// an earlier layer denied, so the gate under test was never exercised.
let r = check_denied(404, "not found", 403, &[]);
assert!(r.is_err());
assert!(r.unwrap_err().contains("expected denial status 403"));
}
#[test]
fn non_4xx_expected_is_a_test_bug() {
// Guards the harness itself: expecting a non-denial status is a test
// authoring error, not a node behavior, and must be rejected.
let r = check_denied(403, "denied", 200, &[]);
assert!(r.is_err());
assert!(r.unwrap_err().contains("not a 4xx"));
}
#[test]
fn empty_withheld_token_never_false_positives() {
// An empty token must be skipped, never treated as "contained in every
// body" — otherwise every denial would spuriously fail as a leak.
let r = check_denied(403, "any body", 403, &[""]);
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn leaking_header_fails_and_names_the_token() {
let withheld = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let headers = format!("X-Debug: object {withheld}");
let r = check_denied_with_headers(
404,
r#"{"error":"repository not found"}"#,
&headers,
404,
&[withheld],
);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("leaked in denial headers"));
assert!(err.contains(withheld));
}
#[test]
fn clean_headers_with_clean_body_passes() {
let withheld = "a1b2c3";
let r = check_denied_with_headers(
404,
r#"{"error":"repository not found"}"#,
"Content-Type: application/json",
404,
&[withheld],
);
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn redact_withheld_replaces_secrets() {
let withheld = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let msg = format!("withheld token {withheld:?} leaked in body: near {withheld}");
let redacted = redact_withheld(&msg, &[withheld]);
assert!(
!redacted.contains(withheld),
"token must be redacted: {redacted}"
);
assert!(
redacted.contains("[REDACTED]"),
"redaction marker must appear: {redacted}"
);
}
#[test]
fn redact_withheld_leaves_clean_strings() {
let result = redact_withheld(
"no secrets here",
&["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"],
);
assert_eq!(result, "no secrets here");
}
}
-33
View File
@@ -1,33 +0,0 @@
//! Shared support for the real-node deny harness: the deny assertion (U4), the
//! RFC-9421 signing client (U2), and (via `gitlawb_node::test_harness`) the
//! node spawner (U3).
pub mod assert;
// U2 fixture state matrix + per-gate-class probe generators; driven by U3.
#[allow(dead_code)]
pub mod probe;
// U1 deny-bearing route registry; fields consumed by U2/U3 as they land.
#[allow(dead_code)]
pub mod routes;
pub mod signing;
/// A reqwest client with a bounded request timeout, so a wedged git subprocess or
/// route fails the suite rather than hanging it until CI kills the job (#195, F1).
/// The two real-node probes that drive git subprocesses use this; the inline
/// twins at the upload-pack and registry-sweep sites use the same pattern.
#[allow(dead_code)]
pub fn bounded_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap()
}
/// Build an anonymous (unsigned) GET request for `path` under `base_url`.
/// Extracted from inline call sites so URL construction goes through a
/// function boundary, matching the `signed_request` pattern in `support::signing`.
#[allow(dead_code)]
pub fn anon_get(client: &reqwest::Client, base_url: &str, path: &str) -> reqwest::RequestBuilder {
let url = format!("{base_url}{path}");
client.get(url)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,32 +0,0 @@
//! Real RFC-9421 request signing for the deny harness (U2). Wraps
//! `gitlawb_core::http_sig::sign_request` (the same entry point the
//! git-remote-gitlawb helper uses in production) so a test can present a valid
//! signature for any identity, reaching the authenticated-but-wrong-owner path
//! that an injected-DID shortcut cannot.
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
/// Build a reqwest request carrying a valid RFC-9421 signature for `keypair`.
///
/// `path` is both appended to `base_url` to form the request URL and signed as
/// the `@path` component, so the value the server reconstructs and the value
/// that was signed are guaranteed identical. `body` is signed via its
/// content-digest and sent as the request body.
pub fn signed_request(
client: &reqwest::Client,
method: reqwest::Method,
base_url: &str,
path: &str,
body: Vec<u8>,
keypair: &Keypair,
) -> reqwest::RequestBuilder {
let sig = sign_request(keypair, method.as_str(), path, &body);
let url = format!("{base_url}{path}");
client
.request(method, url)
.header("content-digest", sig.content_digest)
.header("signature-input", sig.signature_input)
.header("signature", sig.signature)
.body(body)
}
+1 -1
View File
@@ -41,7 +41,7 @@ Owner focus: protocol/auth.
- Close default-open repo write authorization and wire trusted UCAN delegation into repository permissions.
- Close the remaining visibility gaps: restrict or document task, pin, and anchor metadata routes, and publish an irreversible-publication policy.
- Add UCAN revocation or blocklisting, with an emergency compromised-key runbook.
- Add mutation-aware GraphQL auth before GraphQL becomes a public write API surface.
- GraphQL mutations require a verified signer (shipped, #87). Keep the surface limited to the agent-task queue; do not expose an owner-gated repo-write mutation over GraphQL without its owner-gate and REST parity. The `every_graphql_mutation_has_its_gate` fence forces any new mutation to be classified in its table (discovered from the schema, so none can slip in unlisted); the owner-gate and REST parity themselves stay the INV-1 review check, not something the fence proves.
- Harden peer registration and outbound peer calls against SSRF and peer-list poisoning.
- After all live peers upgrade, enable signed peer writes by default.
+1 -1
View File
@@ -92,7 +92,7 @@ Fixed or staged in this pass:
Live-network blockers to prioritize:
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
- GraphQL mutations now require a verified signer bound to the acting DID (#87), and the surface stays limited to the agent-task queue, so there is no public repo-write GraphQL API to gate. Queries remain open by design. (Resolved; kept here as posture, not a live blocker.)
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization. Owner checks are now enforced on every branch, protected or not (`GITLAWB_ENFORCE_OWNER_PUSH`, on by default); what remains is that a UCAN `git/push` capability is not yet honored, so a delegated or CI key cannot push.
- UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature.
- Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired.