Compare commits

..
Author SHA1 Message Date
beardthelion d7da6aa159 fix(node): distinguish missing ref from probe failure in store::log 2026-09-10 21:31:12 -05:00
beardthelion bbf2f876c8 fix: warn or error instead of returning silent empty results
Three sites folded failures into empty results indistinguishable from
genuine emptiness:

- clone.rs encrypted-blobs list fetch: a non-2xx or transport error
  silently returned no recovered paths; now warns via emit_warning,
  matching the per-blob stage in the same function.
- clone.rs arweave fallback: unwrap_or_default() became unwrap_or_else
  that warns, matching the node-recovery arm directly above it.
- changelog handler: store::log failures now return a git error and
  list_prs failures propagate (503 when the pool is unreachable)
  instead of answering 200 with an empty timeline. store::log itself
  only returns empty when the ref genuinely does not resolve: a ref
  that resolves but fails to log is a read failure, not an empty repo.

Closes #400.
2026-09-10 20:17:13 -05:00
8 changed files with 461 additions and 570 deletions

No files matched your search

Generated
+93 -12
View File
@@ -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",
+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.
- 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:
+216 -2
View File
@@ -43,7 +43,10 @@ pub async fn get_changelog(
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let commits = store::log(&disk_path, &head_ref, limit).unwrap_or_default();
// A read failure is not an empty history: returning a bare 200 with no
// events makes a degraded repo look identical to a brand-new one (#400).
let commits =
store::log(&disk_path, &head_ref, limit).map_err(|e| AppError::Git(e.to_string()))?;
let mut events: Vec<serde_json::Value> = commits
.into_iter()
@@ -60,7 +63,9 @@ pub async fn get_changelog(
.collect();
// ── Merged PRs ───────────────────────────────────────────────────────
let prs = state.db.list_prs(&record.id).await.unwrap_or_default();
// Same for the DB half: an outage must surface as an error (503 when the
// pool is unreachable), not an empty timeline (#400).
let prs = state.db.list_prs(&record.id).await?;
for pr in prs.iter().filter(|p| p.status == "merged") {
events.push(serde_json::json!({
"type": "pr_merged",
@@ -88,3 +93,212 @@ pub async fn get_changelog(
"count": events.len(),
})))
}
/// #400: endpoint-level proof that a degraded store or DB reaches the caller
/// as an error, not a 200 with an empty timeline.
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use axum::http::StatusCode;
use axum::Router;
use sqlx::PgPool;
use tempfile::TempDir;
use tower::ServiceExt;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = chrono::Utc::now();
crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// A state whose repo store roots in `repos_dir` so the test controls the
/// on-disk repo, with the repo record already inserted.
async fn seeded_state(
pool: &PgPool,
repos_dir: &std::path::Path,
owner: &str,
name: &str,
) -> AppState {
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store =
crate::git::repo_store::RepoStore::for_testing(repos_dir.to_path_buf(), pool.clone());
state
.db
.create_repo(&seed_repo(owner, name))
.await
.expect("seed repo");
state
}
fn repo_disk_path(repos_dir: &std::path::Path, owner: &str, name: &str) -> std::path::PathBuf {
repos_dir
.join(owner.replace([':', '/'], "_"))
.join(format!("{name}.git"))
}
fn init_bare(path: &std::path::Path) {
std::fs::create_dir_all(path).unwrap();
let out = std::process::Command::new("git")
.args(["init", "--bare"])
.arg(path)
.output()
.unwrap();
assert!(out.status.success());
}
/// A bare repo with one real commit on HEAD.
fn bare_repo_with_commit(
repos_dir: &std::path::Path,
owner: &str,
name: &str,
) -> std::path::PathBuf {
let scratch = repos_dir.join(format!("scratch-{name}"));
let out = std::process::Command::new("git")
.args(["init"])
.arg(&scratch)
.output()
.unwrap();
assert!(out.status.success());
let out = std::process::Command::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"--allow-empty",
"-m",
"initial",
])
.current_dir(&scratch)
.output()
.unwrap();
assert!(out.status.success());
let repo_path = repo_disk_path(repos_dir, owner, name);
std::fs::create_dir_all(repo_path.parent().unwrap()).unwrap();
let out = std::process::Command::new("git")
.args(["clone", "--bare"])
.arg(&scratch)
.arg(&repo_path)
.output()
.unwrap();
assert!(out.status.success());
repo_path
}
/// Delete the object behind HEAD and leave garbage: `git log` fails while
/// `rev-parse` still resolves the ref.
fn corrupt_head_object(repo_path: &std::path::Path) {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_path)
.output()
.unwrap();
let oid = String::from_utf8(out.stdout).unwrap().trim().to_string();
let obj = repo_path.join("objects").join(&oid[..2]).join(&oid[2..]);
std::fs::remove_file(&obj).unwrap();
std::fs::write(&obj, b"garbage").unwrap();
}
async fn oneshot_changelog(
state: AppState,
owner: &str,
name: &str,
) -> axum::response::Response {
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/changelog",
axum::routing::get(get_changelog),
)
.with_state(state)
.oneshot(
Request::builder()
.uri(format!("/api/v1/repos/{owner}/{name}/changelog"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap()
}
/// The git half of the fold: a repo whose HEAD resolves but whose object
/// store is corrupt must be a 500, not a 200 with zero events.
#[sqlx::test]
async fn changelog_on_corrupt_object_store_returns_500_not_empty_200(pool: PgPool) {
let owner = "did:key:zCHANGELOGCORRUPTAAAAAAAAAAAAAAAAAAAA";
let dir = TempDir::new().unwrap();
let state = seeded_state(&pool, dir.path(), owner, "corrupt-log").await;
let repo_path = bare_repo_with_commit(dir.path(), owner, "corrupt-log");
corrupt_head_object(&repo_path);
let resp = oneshot_changelog(state, owner, "corrupt-log").await;
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a resolving ref whose objects cannot be read is a git error"
);
}
/// The DB half of the fold: with the pull_requests table gone, list_prs
/// fails and the endpoint must surface it (503) rather than answering a
/// 200 with only the git-derived events.
#[sqlx::test]
async fn changelog_on_pr_table_failure_returns_error_not_empty_200(pool: PgPool) {
let owner = "did:key:zCHANGELOGDBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let dir = TempDir::new().unwrap();
let state = seeded_state(&pool, dir.path(), owner, "db-fail").await;
init_bare(&repo_disk_path(dir.path(), owner, "db-fail"));
sqlx::query("DROP TABLE pull_requests")
.execute(&pool)
.await
.unwrap();
let resp = oneshot_changelog(state, owner, "db-fail").await;
assert!(
resp.status().is_server_error(),
"a DB failure must not answer a 200 empty timeline; got {}",
resp.status()
);
}
/// Must-not direction: a healthy repo with a commit still returns the
/// event; an empty repo is still a valid empty timeline.
#[sqlx::test]
async fn changelog_still_serves_commit_and_empty_repo(pool: PgPool) {
let owner = "did:key:zCHANGELOGOKCCCCCCCCCCCCCCCCCCCCCCCCCCC";
let dir = TempDir::new().unwrap();
let state = seeded_state(&pool, dir.path(), owner, "with-commit").await;
bare_repo_with_commit(dir.path(), owner, "with-commit");
let resp = oneshot_changelog(state, owner, "with-commit").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 1, "the seeded commit must appear");
assert_eq!(v["events"][0]["type"], "commit");
let state = seeded_state(&pool, dir.path(), owner, "empty-repo").await;
init_bare(&repo_disk_path(dir.path(), owner, "empty-repo"));
let resp = oneshot_changelog(state, owner, "empty-repo").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["count"], 0, "a genuinely empty repo is still 200/empty");
}
}
-548
View File
@@ -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, &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.
+84 -1
View File
@@ -141,7 +141,29 @@ pub fn log(repo_path: &Path, refname: &str, limit: usize) -> Result<Vec<CommitIn
.context("failed to run git log")?;
if !output.status.success() {
return Ok(vec![]); // empty repo
// An unresolvable ref means a genuinely empty repo. A ref that
// resolves but fails to log is a read failure (corrupt object store, a
// gc mid-read): report it rather than passing an empty log off as no
// history (#400). rev-parse resolves the name without reading objects.
// allow-unbounded-git: failure-path existence recheck; module
// convention, at most one extra spawn per failed log.
let resolved = Command::new("git")
.args(["rev-parse", "--verify", "--quiet", refname])
.current_dir(repo_path)
.output()
.context("failed to run git rev-parse")?;
// --quiet exits 1 only for "ref does not resolve"; other failures
// (not a repo, broken config) are real errors, not an empty history.
match resolved.status.code() {
Some(1) => return Ok(vec![]),
Some(0) => {}
_ => {
let stderr = String::from_utf8_lossy(&resolved.stderr);
anyhow::bail!("git rev-parse failed for {refname}: {}", stderr.trim());
}
}
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git log failed for {refname}: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -2025,4 +2047,65 @@ mod tests {
"a clean `missing` twice on a readable store is a genuine absence; got {res:?}"
);
}
// #400: `log` may fold a failed `git log` into Ok(vec![]) only when the ref
// genuinely does not resolve (an empty repo). A ref that resolves but
// fails to read is an error, not an empty history.
#[test]
fn log_empty_is_empty_and_resolved_ref_read_failure_errors() {
let td = tempfile::TempDir::new().unwrap();
let work: &Path = td.path();
let g = |args: &[&str]| {
assert!(Command::new("git")
.args(args)
.current_dir(work)
.status()
.unwrap()
.success());
};
g(&["init", "-q"]);
g(&["config", "user.email", "t@t"]);
g(&["config", "user.name", "t"]);
// No commits: nothing resolves, so a failed log is a real empty repo.
assert!(super::log(work, "HEAD", 10).unwrap().is_empty());
std::fs::write(work.join("f.txt"), b"x\n").unwrap();
g(&["add", "."]);
g(&["commit", "-qm", "c1"]);
assert_eq!(super::log(work, "HEAD", 10).unwrap().len(), 1);
// Corrupt the object the branch resolves to: `git log` fails but
// `rev-parse --verify` still resolves the name, so the failure must
// surface as an error rather than an empty history.
let oid = {
let o = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
let obj = work.join(".git/objects").join(&oid[..2]).join(&oid[2..]);
std::fs::remove_file(&obj).unwrap();
std::fs::write(&obj, b"garbage").unwrap();
assert!(super::log(work, "HEAD", 10).is_err());
}
/// The rev-parse recheck inside `log` must distinguish "ref missing"
/// (exit 1 -> empty history) from a real probe failure (exit 128 on a
/// non-repo -> error), not fold both into `Ok(vec![])`.
#[test]
fn log_probe_failure_on_a_non_repo_errors_instead_of_empty() {
let td = tempfile::TempDir::new().unwrap();
// A directory that is not a git repo at all: `git log` fails and the
// `rev-parse` recheck fails with 128, which is not "missing ref".
let not_a_repo = td.path().join("not-a-repo");
std::fs::create_dir_all(&not_a_repo).unwrap();
assert!(
super::log(&not_a_repo, "HEAD", 10).is_err(),
"a rev-parse probe failure must not read as an empty repo"
);
}
}
+65 -4
View File
@@ -54,6 +54,13 @@ fn emit_warning(line: &str) {
let _ = writeln!(warn_sink(), "{line}");
}
/// Warn-and-degrade for the recovery arms in `run`: a failed recovery source
/// is reported and yields no paths, never silently empty (#400).
fn warn_recovery_failed(what: &str, e: &anyhow::Error) -> Vec<String> {
emit_warning(&format!("warning: {what} failed: {e}"));
Vec::new()
}
#[cfg(not(test))]
fn warn_sink() -> impl std::io::Write {
std::io::stderr()
@@ -322,7 +329,19 @@ async fn recover_encrypted_blobs(
.await
{
Ok(r) if r.status().is_success() => r,
_ => return Ok(vec![]),
Ok(r) => {
emit_warning(&format!(
"warning: could not list encrypted blobs for {owner}/{name}: node returned {}; skipping recovery",
r.status()
));
return Ok(vec![]);
}
Err(e) => {
emit_warning(&format!(
"warning: could not list encrypted blobs for {owner}/{name}: {e}; skipping recovery"
));
return Ok(vec![]);
}
};
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
if body.blobs.is_empty() {
@@ -836,8 +855,7 @@ pub async fn run(args: CloneArgs) -> Result<()> {
// fallback still runs), but the strict /encrypted-blobs parse now
// fails closed on schema drift, so surface it rather than letting
// `.unwrap_or_default()` silently swallow it into "no paths".
eprintln!("warning: encrypted-blobs recovery failed: {e}");
Vec::new()
warn_recovery_failed("encrypted-blobs recovery", &e)
});
let from_arweave = recover_from_arweave(
&args.arweave_gateway,
@@ -848,7 +866,10 @@ pub async fn run(args: CloneArgs) -> Result<()> {
&keypair,
)
.await
.unwrap_or_default();
.unwrap_or_else(|e| {
// Same rule as the node-recovery arm above: warn, never abort.
warn_recovery_failed("arweave/ipfs gateway recovery", &e)
});
paths.extend(from_arweave);
if !paths.is_empty() {
@@ -929,6 +950,46 @@ mod tests {
WARNINGS.with(|w| w.borrow().clone())
}
// #400: the recovery-arm degrade must warn, not silently become "no
// paths". `warn_recovery_failed` is the write both arms go through.
#[test]
fn warn_recovery_failed_warns_and_returns_empty() {
reset_warnings();
let out =
super::warn_recovery_failed("arweave/ipfs gateway recovery", &anyhow::anyhow!("boom"));
assert!(out.is_empty());
assert!(
warnings().contains("arweave/ipfs gateway recovery failed: boom"),
"a recovery failure must warn, got: {:?}",
warnings()
);
}
// #400: the encrypted-blobs LIST fetch must warn on failure instead of
// silently returning an empty recovery set. The per-blob stage already
// warns; the list fetch was the outlier.
#[tokio::test]
async fn encrypted_blobs_list_failure_warns_and_returns_empty() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/repos/o/r/encrypted-blobs")
.with_status(500)
.create_async()
.await;
let kp = gitlawb_core::identity::Keypair::generate();
let dest = TempDir::new().unwrap();
reset_warnings();
let paths = recover_encrypted_blobs(&server.url(), "o", "r", dest.path(), &kp)
.await
.unwrap();
assert!(paths.is_empty());
assert!(
warnings().contains("could not list encrypted blobs"),
"a failed list fetch must warn, got: {:?}",
warnings()
);
}
fn g(args: &[&str], dir: &Path) {
assert!(Command::new("git")
.args(args)
+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.
- 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.
+1 -1
View File
@@ -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.