mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cdedc74d | ||
|
|
efdd3ce45b | ||
|
|
d0ce3176d1 | ||
|
|
e23a4a0c97 | ||
|
|
f00d30c235 | ||
|
|
be61f69c84 | ||
|
|
21d4711dac | ||
|
|
c39f0d2bb1 | ||
|
|
df55f66e86 | ||
|
|
8a4d5a42c1 | ||
|
|
306d8efdae | ||
|
|
8806208ea4 | ||
|
|
f12be9f9a9 | ||
|
|
5feda900e5 | ||
|
|
62563fb97b | ||
|
|
558039021f | ||
|
|
e52629b5a1 | ||
|
|
1568694a16 | ||
|
|
5a05bae436 | ||
|
|
ee140ea181 | ||
|
|
524182f0c2 | ||
|
|
4fff552809 | ||
|
|
14db9c31d0 | ||
|
|
80ffc40774 | ||
|
|
752dfaa9e6 | ||
|
|
8c5613a2ec | ||
|
|
8f98dad632 | ||
|
|
c0fd330f5b | ||
|
|
4f761c8bb2 | ||
|
|
37a025b716 | ||
|
|
ea2db24f24 | ||
|
|
5c82361c91 | ||
|
|
d3877d2cc4 | ||
|
|
063ea75384 | ||
|
|
428ced31dc | ||
|
|
692d8ad7af | ||
|
|
605a63eb37 | ||
|
|
2f1db8b17d | ||
|
|
74bd05c56c | ||
|
|
5a4bee9368 | ||
|
|
5c72cf41d7 | ||
|
|
eb34ed2982 | ||
|
|
2e8ed29f7e | ||
|
|
3fa786d439 | ||
|
|
e16ec0729a | ||
|
|
dd62b4bec1 | ||
|
|
9f88dc0bf3 | ||
|
|
26421e7a39 | ||
|
|
febe42c14d | ||
|
|
9d8cec399a | ||
|
|
29b9918ec6 | ||
|
|
4d21cdb8e2 | ||
|
|
22a584a290 | ||
|
|
13653f55aa | ||
|
|
52b8714e64 | ||
|
|
f656d4859a | ||
|
|
e9cc86748d |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
name: "Gitlawb CodeQL config"
|
||||
paths-ignore:
|
||||
- "**/tests/**"
|
||||
- "**/test_support/**"
|
||||
@@ -116,6 +116,16 @@ 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
+93
-12
@@ -770,6 +770,23 @@ 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"
|
||||
@@ -800,6 +817,16 @@ 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"
|
||||
@@ -838,6 +865,19 @@ 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"
|
||||
@@ -871,6 +911,30 @@ 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"
|
||||
@@ -901,6 +965,16 @@ 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"
|
||||
@@ -3300,19 +3374,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3329,7 +3406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3350,13 +3427,14 @@ dependencies = [
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3412,10 +3490,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
@@ -3464,9 +3543,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -3785,7 +3864,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3821,6 +3900,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5352,7 +5432,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5389,7 +5469,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -5739,14 +5819,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruint"
|
||||
version = "1.17.2"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
|
||||
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
|
||||
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",
|
||||
|
||||
@@ -26,11 +26,15 @@ 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 && \
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
|
||||
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
|
||||
|
||||
See:
|
||||
|
||||
@@ -6,10 +6,28 @@ 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 }
|
||||
@@ -77,6 +95,12 @@ libp2p-dns = { version = "0.44.0", features = ["tokio"] }
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
|
||||
# on which timer fired instead of on wall clock.
|
||||
# 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.
|
||||
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"
|
||||
@@ -279,554 +279,6 @@ 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, ®istered_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.
|
||||
|
||||
@@ -205,8 +205,10 @@ pub struct Config {
|
||||
pub metrics_addr: String,
|
||||
|
||||
/// Maximum time to wait for in-flight requests to drain on shutdown, in
|
||||
/// seconds. After this elapses, the server returns 503 to anything still
|
||||
/// in flight and exits. Default: 30s.
|
||||
/// 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.
|
||||
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
|
||||
pub shutdown_grace_secs: u64,
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ impl Db {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
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(test)]
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
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),
|
||||
(
|
||||
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
|
||||
"manual_ping_uses_readiness_without_mutating_federation_gate",
|
||||
1,
|
||||
),
|
||||
(
|
||||
"manual_ping_uses_readiness_without_mutating_federation_gate",
|
||||
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
|
||||
1,
|
||||
),
|
||||
("mark_peer_ping", 1),
|
||||
|
||||
@@ -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(test)]
|
||||
#[cfg(any(test, feature = "test-harness"))]
|
||||
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
|
||||
Self::new(
|
||||
repos_dir,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+4
-1828
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,586 @@
|
||||
//! 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
@@ -0,0 +1,237 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! 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
@@ -0,0 +1,32 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -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.
|
||||
- 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.
|
||||
- Add mutation-aware GraphQL auth before GraphQL becomes a public write API surface.
|
||||
- Harden peer registration and outbound peer calls against SSRF and peer-list poisoning.
|
||||
- After all live peers upgrade, enable signed peer writes by default.
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ Fixed or staged in this pass:
|
||||
|
||||
Live-network blockers to prioritize:
|
||||
|
||||
- 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.)
|
||||
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
|
||||
- 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.
|
||||
|
||||
Reference in new issue
Block a user