diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e52a949..48dfd3a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,6 +98,14 @@ acquire diff --> parse supported lockfiles --> parse + index --> bounded evidenc Only a complete receipt with no positive counterexample supports a universal claim. Incomplete repository evidence cannot confirm a fresh claim, so the claim is suppressed; a prior-ledger claim remains open until exact-head adjudication resolves it. + Typed Rust source claims cover Copy move-out premises, exact symbol absence, and normalized + signature mismatch. The source verifier parses bounded regular Rust module blobs under `src/`, + including `src/lib.rs` and `src/main.rs` but excluding separate `src/bin` crate roots, from the + immutable reviewed GitHub tree with `syn`; it does not compile, execute, expand macros, follow + symlinks, or infer names. Complete receipts bind the verifier version, head and tree identities, + normalized claim hash, verdict, and hash-only source evidence. Fresh claims without a conclusive + receipt are suppressed. Carried claims require an exact-head refutation to resolve and otherwise + remain visible as deferred, nonblocking findings. Every surviving generated candidate, plus applicable baseline candidates during a full rereview, enters one bounded adjudication operation before scoring and publication. The operation admits the complete candidate set or fails closed before provider contact. Its @@ -280,6 +288,12 @@ additions (violations are `kind: contentPolicy`). Content policy is on by defaul cannot expand or enter schema repair. Confirmation and refutation require exact supplied evidence; repository-wide conclusions additionally require a complete receipt for the exact snapshot. Public rewrites describe only the defect, impact, and correction. +16. Typed machine claims never substitute model prose for source proof. Verification parses a + bounded immutable Rust source snapshot without compilation or execution, rejects ambiguous + symbols and unsupported syntax, and emits only hash-bound evidence. A fresh unsupported, + unresolved, unavailable, exhausted, or refuted claim cannot reach the gate. A carried claim + remains visible but nonblocking until an exact-head refutation resolves it. Fresh independently + generated claims still require a supported receipt. ## Residual prompt-injection surface diff --git a/Cargo.lock b/Cargo.lock index e508114..5eadda6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,6 +1136,7 @@ dependencies = [ "sha1", "sha2", "similar", + "syn", "tempfile", "time", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 60e93b3..b4041d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ serde_json = { version = "1.0.150", features = ["raw_value"] } sha1 = "0.11.0" sha2 = "0.11.0" similar = "3.1.1" +syn = { version = "2", features = ["full", "visit"] } tempfile = "3.27.0" time = { version = "0.3.54", features = ["parsing", "formatting"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "time"] } diff --git a/bench/review-contract-sources.json b/bench/review-contract-sources.json index d7d6353..1403436 100644 --- a/bench/review-contract-sources.json +++ b/bench/review-contract-sources.json @@ -25,6 +25,7 @@ "src/llm.rs", "src/local.rs", "src/login.rs", + "src/machine_claim.rs", "src/main.rs", "src/output.rs", "src/plan.rs", diff --git a/docs/automation.md b/docs/automation.md index 64518b0..1c75407 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -4,7 +4,7 @@ `postil review --output json` and `--output yaml` emit the complete versioned review envelope. `--output csv` emits a flattened findings table with review and gate fields repeated on each row. `--output-file ` keeps structured output separate from human-readable output. -The JSON and YAML envelope contains the summary, findings, resolved findings, policy counts, gate result, model and scorer metadata, token usage, per-model usage, and reviewed SHAs. `usageAccountingComplete` is false when a provider request can have unknown billed usage. +The JSON and YAML envelope contains the summary, findings, resolved findings, policy counts, gate result, model and scorer metadata, token usage, per-model usage, and reviewed SHAs. A finding can include a typed `machineClaim`; `claimVerification` records its exact-head, hash-only source receipt, and `machineClaimDeferred` marks a carried claim that remains visible without blocking. Both fields are optional during deserialization. `usageAccountingComplete` is false when a provider request can have unknown billed usage. `--sarif ` writes SARIF 2.1.0 for code-scanning ingestion. diff --git a/src/adjudication.rs b/src/adjudication.rs index 1724ae8..5a68f2a 100644 --- a/src/adjudication.rs +++ b/src/adjudication.rs @@ -1646,6 +1646,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: title.into(), body: body.into(), evidence: Some("uses: action@old".into()), diff --git a/src/brevity.rs b/src/brevity.rs index 4749097..5eb8941 100644 --- a/src/brevity.rs +++ b/src/brevity.rs @@ -146,6 +146,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Keep the stable finding metadata".to_string(), body, evidence: Some("changed_call();".to_string()), diff --git a/src/envelope.rs b/src/envelope.rs index 9f6fd40..9cdda3e 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -114,6 +114,14 @@ pub struct Finding { default )] pub repository_claim: Option, + /// Optional deterministic source premise that Postil can verify against + /// the immutable reviewed repository without compiling or executing it. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub machine_claim: Option, + /// Carried machine claims stay visible when current source cannot settle + /// them, but they do not block until a fresh supported receipt exists. + #[serde(skip_serializing_if = "is_false", default)] + pub machine_claim_deferred: bool, pub title: String, pub body: String, /// Exact new-side text canonicalized from the cited prompt line. This is @@ -127,6 +135,115 @@ pub struct Finding { pub id: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MachineClaimKind { + #[serde(rename = "rust.copy_move_out")] + RustCopyMoveOut, + #[serde(rename = "symbol.absent")] + SymbolAbsent, + #[serde(rename = "signature.mismatch")] + SignatureMismatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MachineReceiver { + None, + Shared, + Mutable, + Value, +} + +/// Bounded function shape accepted by `signature.mismatch`. +/// +/// Parameter and return types use a deliberately small Rust type grammar: +/// paths, references without explicit lifetimes, tuples, slices, and generic +/// type arguments composed from those forms. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineSignature { + pub receiver: MachineReceiver, + #[serde(default)] + pub parameters: Vec, + pub returns: String, + #[serde(rename = "async", default, skip_serializing_if = "is_false")] + pub is_async: bool, + #[serde(rename = "unsafe", default, skip_serializing_if = "is_false")] + pub is_unsafe: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineClaim { + pub kind: MachineClaimKind, + /// Exact regular Rust source file in the immutable repository tree. + pub path: String, + /// Exact `crate::`-qualified type, item, trait member, or inherent method. + pub symbol: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub expected_signature: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClaimVerificationState { + Complete, + Exhausted, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClaimVerificationVerdict { + Supported, + Refuted, + Unresolved, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClaimEvidenceRole { + CopyDerive, + CopyImplementation, + SymbolDefinition, + Signature, + SourceScope, +} + +/// Hash-only source evidence. No repository source text is serialized. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimVerificationEvidence { + pub role: ClaimEvidenceRole, + pub path_sha256: String, + pub source_sha256: String, + pub span_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedMachineClaim { + pub claim_input_sha256: String, + pub verdict: ClaimVerificationVerdict, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimVerificationReceipt { + pub verifier: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub head_sha: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tree_sha256: Option, + pub state: ClaimVerificationState, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub claims: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum RepositoryClaimKind { @@ -1182,6 +1299,10 @@ pub enum SuppressionReason { /// A repository-wide absence or mismatch claim was not supported by one /// complete immutable-head receipt, or a positive counterexample refuted it. RepositoryClaimUnsupported, + /// A deterministic exact-head source proof contradicted the claim. + MachineClaimRefuted, + /// The source verifier could not produce a bounded conclusive proof. + MachineClaimUnverified, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1580,6 +1701,10 @@ pub struct Envelope { /// mismatch claims. Historical v1 envelopes deserialize as unavailable. #[serde(default)] pub repository_search: RepositorySearchReceipt, + /// Bounded deterministic source verification for typed machine claims. + /// Historical v1 envelopes omit this additive field. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub claim_verification: Option, /// False when any sent provider request can have unknown billed usage, /// including timeouts and ambiguous transport failures. #[serde(default)] @@ -1628,6 +1753,9 @@ pub fn finding_blocks_gate( block_on_kinds: &[String], provider_error_is_advisory: bool, ) -> bool { + if finding.machine_claim_deferred { + return false; + } if fail_on.eq_ignore_ascii_case("never") { return false; } @@ -1732,6 +1860,8 @@ pub(crate) fn incomplete_review_finding(reason: IncompleteReviewReason) -> Findi scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, } } @@ -1766,6 +1896,8 @@ pub fn operational_failure_finding(detail: &str) -> Finding { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, } } @@ -1797,6 +1929,8 @@ pub fn fail_closed_finding(detail: &str) -> Finding { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, } } @@ -1832,6 +1966,8 @@ pub fn narrated_risk_finding(summary: &str) -> Finding { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, } } @@ -1856,6 +1992,8 @@ pub fn provider_error_finding(_detail: &str) -> Finding { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, } } @@ -2028,6 +2166,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "t".into(), body: "b".into(), evidence: None, @@ -2381,6 +2521,25 @@ mod tests { assert_eq!(contextual_value["repositoryContext"]["claim"], "absence"); assert!(contextual_value.get("repositoryClaim").is_none()); + let mut machine_finding = finding(Severity::Warn, 0.8); + machine_finding.machine_claim = Some(MachineClaim { + kind: MachineClaimKind::RustCopyMoveOut, + path: "src/identity.rs".into(), + symbol: "crate::identity::IdentityFailure".into(), + expected_signature: None, + }); + let machine_value = serde_json::to_value(&machine_finding).unwrap(); + assert_eq!(machine_value["machineClaim"]["kind"], "rust.copy_move_out"); + assert!(machine_value.get("machineClaimDeferred").is_none()); + let mut legacy_machine_value = machine_value; + legacy_machine_value + .as_object_mut() + .unwrap() + .remove("machineClaim"); + let legacy_machine_finding: Finding = serde_json::from_value(legacy_machine_value).unwrap(); + assert!(legacy_machine_finding.machine_claim.is_none()); + assert!(!legacy_machine_finding.machine_claim_deferred); + let mut env = Envelope { version: 1, summary: String::new(), @@ -2405,6 +2564,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: RepositorySearchReceipt::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -2454,6 +2614,7 @@ mod tests { historical.repository_search.state, RepositorySearchState::Unavailable ); + assert!(historical.claim_verification.is_none()); v.as_object_mut().unwrap().remove("modelIncidents"); let decoded: Envelope = serde_json::from_value(v).unwrap(); diff --git a/src/filter.rs b/src/filter.rs index d852e86..50f8dff 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -718,6 +718,19 @@ fn supersedes(base: &Finding, new: &Finding) -> bool { { return base.path == new.path && base.id.is_some() && base.id == new.id; } + let base_machine_claim = base + .machine_claim + .as_ref() + .map(crate::machine_claim::claim_identity_sha256); + let new_machine_claim = new + .machine_claim + .as_ref() + .map(crate::machine_claim::claim_identity_sha256); + if (base_machine_claim.is_some() || new_machine_claim.is_some()) + && base_machine_claim != new_machine_claim + { + return false; + } if defect_identity(base) == defect_identity(new) && new.severity >= base.severity { return true; } @@ -727,12 +740,24 @@ fn supersedes(base: &Finding, new: &Finding) -> bool { && new.severity >= base.severity } -fn defect_identity(finding: &Finding) -> (String, crate::envelope::Kind, String, Option) { +type DefectIdentity = ( + String, + crate::envelope::Kind, + String, + Option, + Option, +); + +fn defect_identity(finding: &Finding) -> DefectIdentity { ( finding.path.to_ascii_lowercase(), finding.kind, finding.title.trim().to_ascii_lowercase(), finding.evidence.clone(), + finding + .machine_claim + .as_ref() + .map(crate::machine_claim::claim_identity_sha256), ) } @@ -760,12 +785,7 @@ fn touch_addresses(index: &DiffIndex, f: &Finding, scope: ReconcileScope) -> boo fn push_carried( carried: &mut Vec, - identities: &mut std::collections::HashSet<( - String, - crate::envelope::Kind, - String, - Option, - )>, + identities: &mut std::collections::HashSet, mut finding: Finding, ) { if !is_carried(&finding) { @@ -800,6 +820,12 @@ pub fn reconcile( // copy is already in `new_findings` and will reach the gate. continue; } + // A typed carried claim is settled only by the exact-head verifier. + // Generic touched-line reconciliation cannot clear it. + if f.machine_claim_deferred { + push_carried(&mut carried, &mut carried_identities, f.clone()); + continue; + } if let ReconcileScope::Incremental { trust } = scope && index.contains_old(&f.path, f.line) { @@ -865,6 +891,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "t".into(), body: "b".into(), evidence: Some("x".into()), @@ -1139,6 +1167,65 @@ mod tests { assert!(rec.carried[1].body.starts_with("[carried")); } + #[test] + fn reconcile_never_clears_a_deferred_machine_claim_without_source_refutation() { + let idx = index_for("src/identity.rs", 10, 3); + let mut baseline = f("src/identity.rs", 11, Severity::Info, 0.9); + baseline.machine_claim = Some(crate::envelope::MachineClaim { + kind: crate::envelope::MachineClaimKind::RustCopyMoveOut, + path: "src/identity.rs".into(), + symbol: "crate::identity::IdentityFailure".into(), + expected_signature: None, + }); + baseline.machine_claim_deferred = true; + let mut unrelated = f("src/identity.rs", 11, Severity::Error, 0.95); + unrelated.title = baseline.title.clone(); + + let rec = reconcile( + std::slice::from_ref(&baseline), + &idx, + &[unrelated], + ReconcileScope::Incremental { + trust: ReviewTrust::Exhaustive, + }, + ); + + assert!(rec.resolved.is_empty()); + assert_eq!(rec.carried.len(), 1); + assert_eq!(rec.carried[0].machine_claim, baseline.machine_claim); + assert!(rec.carried[0].machine_claim_deferred); + } + + #[test] + fn canonical_machine_claims_supersede_formatting_variants() { + let mut baseline = f("src/identity.rs", 11, Severity::Info, 0.9); + baseline.machine_claim = Some(crate::envelope::MachineClaim { + kind: crate::envelope::MachineClaimKind::SignatureMismatch, + path: "src/identity.rs".into(), + symbol: "crate::identity::check".into(), + expected_signature: Some(crate::envelope::MachineSignature { + receiver: crate::envelope::MachineReceiver::None, + parameters: vec!["std::vec::Vec < u8 >".into()], + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }), + }); + let mut fresh = baseline.clone(); + fresh.severity = Severity::Error; + fresh.machine_claim.as_mut().unwrap().expected_signature = + Some(crate::envelope::MachineSignature { + receiver: crate::envelope::MachineReceiver::None, + parameters: vec!["std::vec::Vec".into()], + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }); + + assert!(supersedes(&baseline, &fresh)); + assert_eq!(defect_identity(&baseline), defect_identity(&fresh)); + } + #[test] fn reviewable_virtual_anchors_carry_but_operational_anchors_expire() { let idx = index_for("unrelated.rs", 1, 1); diff --git a/src/forge/github.rs b/src/forge/github.rs index 409399e..f7472c1 100644 --- a/src/forge/github.rs +++ b/src/forge/github.rs @@ -1138,6 +1138,209 @@ impl GitHub { } } + pub(crate) async fn verify_machine_claims_at_head( + &self, + head_sha: &str, + claims: &[crate::envelope::MachineClaim], + ) -> crate::envelope::ClaimVerificationReceipt { + let deadline = crate::repository_search::github_aggregate_deadline(); + match tokio::time::timeout( + deadline, + self.machine_claim_source_at_head(head_sha, claims), + ) + .await + { + Err(_) => crate::machine_claim::exhausted_receipt(Some(head_sha), claims), + Ok(Ok(snapshot)) => crate::machine_claim::verify_snapshot(snapshot, claims), + Ok(Err(error)) + if error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some() + }) => + { + crate::machine_claim::exhausted_receipt(Some(head_sha), claims) + } + Ok(Err(_)) => crate::machine_claim::unavailable_receipt(Some(head_sha), claims), + } + } + + async fn machine_claim_source_at_head( + &self, + head_sha: &str, + claims: &[crate::envelope::MachineClaim], + ) -> Result { + ensure!( + crate::repository_search::valid_full_object_id(head_sha), + "machine claim verification requires a full commit SHA" + ); + if claims.len() > crate::machine_claim::MAX_CLAIMS { + return Err(anyhow::Error::new(MachineClaimSourceExhausted)); + } + let response = self + .request( + reqwest::Method::GET, + self.url(&format!("/git/commits/{head_sha}")), + ) + .send() + .await + .context("fetching machine claim commit")?; + let commit: GitCommitResponse = super::bounded_response_json( + Self::check_ok(response, "machine claim commit").await?, + "GitHub machine claim commit", + ) + .await?; + ensure!( + commit.sha.eq_ignore_ascii_case(head_sha), + "machine claim commit did not match the reviewed head" + ); + ensure!( + crate::repository_search::valid_full_object_id(&commit.tree.sha), + "machine claim commit returned an invalid tree id" + ); + + let mut tree_url = + reqwest::Url::parse(&self.url(&format!("/git/trees/{}", commit.tree.sha))) + .context("building machine claim tree URL")?; + tree_url.query_pairs_mut().append_pair("recursive", "1"); + let response = self + .request(reqwest::Method::GET, tree_url.to_string()) + .send() + .await + .context("fetching machine claim tree")?; + let tree: GitTreeResponse = super::bounded_response_json( + Self::check_ok(response, "machine claim tree").await?, + "GitHub machine claim tree", + ) + .await?; + ensure!( + tree.sha.eq_ignore_ascii_case(&commit.tree.sha), + "machine claim tree did not match the reviewed head" + ); + ensure!(!tree.truncated, "machine claim tree was incomplete"); + if tree.tree.len() > crate::machine_claim::MAX_TREE_ENTRIES { + return Err(anyhow::Error::new(MachineClaimSourceExhausted)); + } + + let mut seen_paths = HashSet::with_capacity(tree.tree.len()); + let mut snapshot_entries = Vec::new(); + let mut rust_blobs = Vec::new(); + let mut total_source_bytes = 0usize; + for entry in tree.tree { + ensure!( + super::valid_repository_path(&entry.path) && seen_paths.insert(entry.path.clone()), + "machine claim tree returned an unsafe or duplicate path" + ); + ensure!( + crate::repository_search::valid_full_object_id(&entry.sha), + "machine claim tree returned an invalid object id" + ); + match (entry.kind.as_str(), entry.mode.as_str()) { + ("tree", "040000") => { + ensure!(entry.size.is_none(), "machine claim tree entry had a size"); + } + ("blob", "100644" | "100755" | "120000") => { + let size = entry + .size + .context("machine claim tree blob omitted its size")?; + snapshot_entries.push(crate::repository_search::RepositorySnapshotEntry { + path: entry.path.clone(), + object_id: entry.sha.clone(), + mode: entry.mode.clone(), + kind: crate::repository_search::RepositorySnapshotEntryKind::Blob, + size: Some(size), + }); + if entry.path.starts_with("src/") && entry.path.ends_with(".rs") { + ensure!( + entry.mode != "120000", + "machine claim source path is a symlink" + ); + if !crate::machine_claim::regular_rust_source_entry( + &entry.path, + &entry.mode, + ) { + continue; + } + let size = usize::try_from(size) + .map_err(|_| anyhow::Error::new(MachineClaimSourceExhausted))?; + if size > crate::machine_claim::MAX_SOURCE_FILE_BYTES { + return Err(anyhow::Error::new(MachineClaimSourceExhausted)); + } + total_source_bytes = total_source_bytes + .checked_add(size) + .ok_or_else(|| anyhow::Error::new(MachineClaimSourceExhausted))?; + if total_source_bytes > crate::machine_claim::MAX_SOURCE_BYTES { + return Err(anyhow::Error::new(MachineClaimSourceExhausted)); + } + rust_blobs.push((entry.path, entry.sha, size)); + } + } + ("commit", "160000") => { + ensure!(entry.size.is_none(), "machine claim gitlink had a size"); + snapshot_entries.push(crate::repository_search::RepositorySnapshotEntry { + path: entry.path, + object_id: entry.sha, + mode: entry.mode, + kind: crate::repository_search::RepositorySnapshotEntryKind::Gitlink, + size: None, + }); + } + _ => { + return Err(anyhow!( + "machine claim tree returned an unsupported object type or mode" + )); + } + } + } + if rust_blobs.len() > crate::machine_claim::MAX_SOURCE_FILES { + return Err(anyhow::Error::new(MachineClaimSourceExhausted)); + } + snapshot_entries.sort_by(|left, right| left.path.cmp(&right.path)); + let tree_sha256 = crate::repository_search::tree_sha256(&snapshot_entries); + rust_blobs.sort_by(|left, right| left.0.cmp(&right.0)); + + let mut files = Vec::with_capacity(rust_blobs.len()); + for (path, object_id, expected_size) in rust_blobs { + let response = self + .request( + reqwest::Method::GET, + self.url(&format!("/git/blobs/{object_id}")), + ) + .header("Accept", "application/vnd.github.raw+json") + .send() + .await + .with_context(|| format!("fetching machine claim source object for {path}"))?; + let mut response = Self::check_ok(response, "machine claim source object").await?; + let bytes = super::bounded_response_bytes_with_limit( + &mut response, + "GitHub machine claim source object", + crate::machine_claim::MAX_SOURCE_FILE_BYTES, + ) + .await?; + ensure!( + bytes.len() == expected_size, + "machine claim source object size changed" + ); + let mut object_hash = + crate::repository_search::GitObjectHash::new("blob", expected_size as u64); + object_hash.update(&bytes); + ensure!( + object_hash.matches(&object_id), + "machine claim source object did not match its tree identity" + ); + files.push(crate::machine_claim::MachineSourceFile { + path, + source: String::from_utf8(bytes) + .context("machine claim source object is not valid UTF-8")?, + }); + } + Ok(crate::machine_claim::MachineSourceSnapshot { + head_sha: head_sha.to_ascii_lowercase(), + tree_sha256, + files, + }) + } + async fn search_repository_at_head_inner( &self, head_sha: &str, @@ -1707,6 +1910,17 @@ impl std::fmt::Display for RepositorySearchExhausted { impl std::error::Error for RepositorySearchExhausted {} +#[derive(Debug)] +struct MachineClaimSourceExhausted; + +impl std::fmt::Display for MachineClaimSourceExhausted { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("machine claim source budget exhausted") + } +} + +impl std::error::Error for MachineClaimSourceExhausted {} + struct RepositorySearchBudget { started_at: Instant, requests: usize, @@ -4385,6 +4599,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: Some(base_sha.into()), @@ -4423,6 +4638,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: format!("Finding {id}"), body: body.into(), evidence: Some("let value = risky();".into()), @@ -4784,6 +5001,97 @@ mod tests { assert!(error.to_string().contains("500")); } + #[tokio::test] + async fn machine_claim_verification_reads_only_exact_head_tree_objects() { + let server = MockServer::start().await; + let head = "a".repeat(40); + let tree = "b".repeat(40); + let source = b"mod identity { pub struct IdentityFailure; impl ::core::marker::Copy for IdentityFailure {} impl Clone for IdentityFailure { fn clone(&self) -> Self { *self } } }\n"; + let blob = crate::repository_search::git_blob_sha1(source); + Mock::given(method("GET")) + .and(path(format!("/repos/owner/repo/git/commits/{head}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "sha": head, + "tree": {"sha": tree} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/repos/owner/repo/git/trees/{tree}"))) + .and(query_param("recursive", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "sha": tree, + "truncated": false, + "tree": [{ + "path": "src/lib.rs", + "mode": "100644", + "type": "blob", + "sha": blob, + "size": source.len() + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/repos/owner/repo/git/blobs/{blob}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(source.to_vec())) + .expect(1) + .mount(&server) + .await; + let claim = crate::envelope::MachineClaim { + kind: crate::envelope::MachineClaimKind::RustCopyMoveOut, + path: "src/lib.rs".into(), + symbol: "crate::identity::IdentityFailure".into(), + expected_signature: None, + }; + + let receipt = test_github(&server) + .verify_machine_claims_at_head(&head, &[claim]) + .await; + + assert_eq!( + receipt.state, + crate::envelope::ClaimVerificationState::Complete + ); + assert_eq!(receipt.head_sha.as_deref(), Some(head.as_str())); + assert!(receipt.tree_sha256.is_some()); + assert_eq!( + receipt.claims[0].verdict, + crate::envelope::ClaimVerificationVerdict::Refuted + ); + } + + #[tokio::test] + async fn machine_claim_verification_is_unavailable_without_exact_head_source() { + let server = MockServer::start().await; + let head = "a".repeat(40); + Mock::given(method("GET")) + .and(path(format!("/repos/owner/repo/git/commits/{head}"))) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + let claim = crate::envelope::MachineClaim { + kind: crate::envelope::MachineClaimKind::SymbolAbsent, + path: "src/identity.rs".into(), + symbol: "crate::identity::IdentityFailure".into(), + expected_signature: None, + }; + + let receipt = test_github(&server) + .verify_machine_claims_at_head(&head, &[claim]) + .await; + + assert_eq!( + receipt.state, + crate::envelope::ClaimVerificationState::Unavailable + ); + assert_eq!(receipt.head_sha.as_deref(), Some(head.as_str())); + assert!(receipt.tree_sha256.is_none()); + } + #[tokio::test] async fn repository_search_walks_nested_trees_and_every_blob_at_the_pinned_head() { use crate::envelope::{ @@ -7717,6 +8025,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Finding".into(), body: "A concrete issue.".into(), evidence: None, @@ -7782,6 +8092,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Finding".into(), body: "A concrete issue.".into(), evidence: None, @@ -7864,6 +8176,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Finding".into(), body: "A concrete issue.".into(), evidence: None, @@ -7968,6 +8282,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Finding".into(), body: "A concrete issue.".into(), evidence: None, @@ -8060,6 +8376,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Preserve the complete finding".into(), body: format!("{}.", "a".repeat(226)), evidence: Some("let vulnerable = true;".into()), @@ -8089,6 +8407,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: Some("cccccccccccc".into()), @@ -8229,6 +8548,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Human judgment required".into(), body: "Concrete compatibility concern.".into(), evidence: None, @@ -8258,6 +8579,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -8296,6 +8618,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -8326,6 +8649,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "rgwConfig may not be a recognized field".into(), body: "The chart may silently ignore this block.".into(), evidence: None, diff --git a/src/forge/mod.rs b/src/forge/mod.rs index 6b1222d..6ca7e1f 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -2044,6 +2044,10 @@ fn suppression_reason(reason: SuppressionReason) -> &'static str { } SuppressionReason::DerivedFromSuppressed => "built on a finding suppressed as mis-anchored", SuppressionReason::RepositoryClaimUnsupported => "repository-wide claim is not publishable", + SuppressionReason::MachineClaimRefuted => "source premise was deterministically refuted", + SuppressionReason::MachineClaimUnverified => { + "source premise could not be verified within deterministic bounds" + } } } @@ -3059,6 +3063,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Unsanitized input reaches query".into(), body: "user_input flows into exec_query.".into(), evidence: None, @@ -3091,6 +3097,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -3212,6 +3219,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 1_250, base_sha: None, @@ -3566,6 +3574,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -3633,6 +3642,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -3682,6 +3692,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, diff --git a/src/lib.rs b/src/lib.rs index 77192d4..93c4b7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod hook; pub mod llm; pub mod local; pub mod login; +pub(crate) mod machine_claim; pub mod output; pub mod plan; pub mod prompt; diff --git a/src/llm.rs b/src/llm.rs index c3ce009..ae14d25 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -518,6 +518,8 @@ struct RawFinding { kind: Option, #[serde(default)] repository_context: RawRepositoryContext, + #[serde(default)] + machine_claim: Option, #[serde(default = "default_confidence")] confidence: f64, #[serde(default)] @@ -6196,6 +6198,8 @@ fn into_review(raw: RawReview, model: &str, usage: Usage) -> ModelReview { scorer_kind: None, scorer_reason: None, repository_claim, + machine_claim: f.machine_claim, + machine_claim_deferred: false, title: f.title, body: f.body, evidence: f.evidence, @@ -9216,6 +9220,7 @@ mod tests { severity: label.into(), kind: None, repository_context: RawRepositoryContext::default(), + machine_claim: None, confidence: 0.9, title: "real issue".into(), body: "still grounded".into(), @@ -9241,6 +9246,7 @@ mod tests { severity: "CRITICAL".into(), kind: Some("human_escalation".into()), repository_context: RawRepositoryContext::default(), + machine_claim: None, confidence: 1.7, title: "".into(), body: "a body".into(), @@ -9268,6 +9274,7 @@ mod tests { severity: "info".into(), kind: Some("contentPolicy".into()), repository_context: RawRepositoryContext::default(), + machine_claim: None, confidence: 0.8, title: "Stale temporal residue".into(), body: "b".into(), @@ -9296,6 +9303,7 @@ mod tests { paths: vec!["generated/cluster.yaml".into()], identifiers: vec!["clusterVersion".into()], }, + machine_claim: None, confidence: 0.8, title: "Version counterparts disagree".into(), body: "The cluster version does not match its generated counterpart.".into(), @@ -9323,6 +9331,7 @@ mod tests { severity: "warn".into(), kind: Some("risk".into()), repository_context: RawRepositoryContext::default(), + machine_claim: None, confidence: 0.8, title: "@octocat **unsafe**".into(), body: format!( @@ -9358,6 +9367,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "t".into(), body: "b".into(), evidence: None, @@ -9392,6 +9403,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "solo".into(), body: "b".into(), evidence: None, @@ -9436,6 +9449,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "t2".into(), body: "b2".into(), evidence: None, diff --git a/src/local.rs b/src/local.rs index f1ef7b6..4020f7b 100644 --- a/src/local.rs +++ b/src/local.rs @@ -227,6 +227,8 @@ mod tests { paths: vec![], identifiers: vec![], }), + machine_claim: None, + machine_claim_deferred: false, title: "Repository state".into(), body: "The immutable index tree contains the reviewed value.".into(), evidence: Some("version: staged-one".into()), diff --git a/src/machine_claim.rs b/src/machine_claim.rs new file mode 100644 index 0000000..883d06c --- /dev/null +++ b/src/machine_claim.rs @@ -0,0 +1,2935 @@ +//! Deterministic, no-execution verification for narrowly typed source claims. + +use std::collections::{BTreeMap, BTreeSet}; + +use sha2::{Digest, Sha256}; +use syn::punctuated::Punctuated; +use syn::visit::Visit; +use syn::{Attribute, FnArg, GenericArgument, Item, PathArguments, ReturnType, Token, Type}; + +use crate::envelope::{ + ClaimEvidenceRole, ClaimVerificationEvidence, ClaimVerificationReceipt, ClaimVerificationState, + ClaimVerificationVerdict, MachineClaim, MachineClaimKind, MachineReceiver, MachineSignature, + VerifiedMachineClaim, +}; +use crate::repository_search::RepositorySource; + +pub(crate) const VERIFIER_VERSION: &str = "postil-source-v1"; +pub(crate) const MAX_CLAIMS: usize = 20; +pub(crate) const MAX_SOURCE_FILES: usize = 128; +pub(crate) const MAX_SOURCE_FILE_BYTES: usize = 512 * 1024; +pub(crate) const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024; +pub(crate) const MAX_TREE_ENTRIES: usize = 20_000; +pub(crate) const MAX_EVIDENCE_PER_CLAIM: usize = 4; +pub(crate) const MAX_SYNTAX_NODES: usize = 200_000; +const MAX_RECEIPT_BYTES: usize = 64 * 1024; +const MAX_PATH_BYTES: usize = 512; +const MAX_SYMBOL_BYTES: usize = 512; +const MAX_SIGNATURE_PARAMETERS: usize = 16; +const MAX_SIGNATURE_TYPE_BYTES: usize = 256; +const MAX_SIGNATURE_TOTAL_BYTES: usize = 2 * 1024; + +#[derive(Debug, Clone)] +pub(crate) struct MachineSourceFile { + pub(crate) path: String, + pub(crate) source: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct MachineSourceSnapshot { + pub(crate) head_sha: String, + pub(crate) tree_sha256: String, + pub(crate) files: Vec, +} + +pub(crate) async fn verify( + source: &RepositorySource<'_>, + head_sha: Option<&str>, + findings: impl Iterator, +) -> Option { + let mut claims = findings + .filter_map(|finding| finding.machine_claim.as_ref()) + .cloned() + .collect::>(); + let mut seen = BTreeSet::new(); + claims.retain(|claim| seen.insert(claim_input_sha256(claim))); + if claims.is_empty() { + return None; + } + let Some(head_sha) = head_sha.filter(|value| !value.is_empty()) else { + return Some(unavailable_receipt(None, &claims)); + }; + if claims.len() > MAX_CLAIMS || !crate::repository_search::valid_full_object_id(head_sha) { + return Some(exhausted_receipt(Some(head_sha), &claims)); + } + match source { + RepositorySource::GitHub(github) => Some( + github + .verify_machine_claims_at_head(head_sha, &claims) + .await, + ), + // A moving worktree is not exact-head evidence. Local review remains + // fail-closed until immutable object reads can be performed without a + // subprocess or build-capable repository helper. + RepositorySource::Local(_) | RepositorySource::Unavailable => { + Some(unavailable_receipt(Some(head_sha), &claims)) + } + } +} + +pub(crate) fn claim_is_valid(claim: &MachineClaim) -> bool { + normalize_claim(claim).is_some() +} + +pub(crate) fn regular_rust_source_entry(path: &str, mode: &str) -> bool { + matches!(mode, "100644" | "100755") && valid_rust_path(path) && module_for_path(path).is_some() +} + +pub(crate) fn receipt_verdict( + receipt: Option<&ClaimVerificationReceipt>, + claim: &MachineClaim, + expected_head_sha: Option<&str>, +) -> Option { + let receipt = receipt?; + let expected_head_sha = expected_head_sha?; + if receipt.verifier != VERIFIER_VERSION + || receipt.state != ClaimVerificationState::Complete + || receipt.head_sha.as_deref() != Some(expected_head_sha) + || !crate::repository_search::valid_full_object_id(expected_head_sha) + || !receipt.tree_sha256.as_deref().is_some_and(valid_sha256) + || receipt.claims.len() > MAX_CLAIMS + || receipt.claims.iter().any(|proof| { + !valid_sha256(&proof.claim_input_sha256) + || proof.evidence.len() > MAX_EVIDENCE_PER_CLAIM + || proof.evidence.iter().any(|evidence| { + !valid_sha256(&evidence.path_sha256) + || !valid_sha256(&evidence.source_sha256) + || !valid_sha256(&evidence.span_sha256) + }) + }) + || serde_json::to_vec(receipt) + .map_or(true, |serialized| serialized.len() > MAX_RECEIPT_BYTES) + { + return None; + } + let input = claim_input_sha256(claim); + let mut matches = receipt + .claims + .iter() + .filter(|proof| proof.claim_input_sha256 == input); + let first = matches.next()?; + matches.next().is_none().then_some(first.verdict) +} + +pub(crate) fn verify_snapshot( + snapshot: MachineSourceSnapshot, + claims: &[MachineClaim], +) -> ClaimVerificationReceipt { + if claims.len() > MAX_CLAIMS + || snapshot.files.len() > MAX_SOURCE_FILES + || !crate::repository_search::valid_full_object_id(&snapshot.head_sha) + || !valid_sha256(&snapshot.tree_sha256) + { + return exhausted_receipt(Some(&snapshot.head_sha), claims); + } + let mut source_bytes = 0usize; + let mut seen_paths = BTreeSet::new(); + for file in &snapshot.files { + source_bytes = match source_bytes.checked_add(file.source.len()) { + Some(total) => total, + None => return exhausted_receipt(Some(&snapshot.head_sha), claims), + }; + if file.source.len() > MAX_SOURCE_FILE_BYTES + || source_bytes > MAX_SOURCE_BYTES + || !valid_rust_path(&file.path) + || !seen_paths.insert(file.path.as_str()) + { + return exhausted_receipt(Some(&snapshot.head_sha), claims); + } + } + + let Some(repository) = ParsedRepository::parse(&snapshot.files) else { + return exhausted_receipt(Some(&snapshot.head_sha), claims); + }; + let fallback_head_sha = snapshot.head_sha.clone(); + let verified = claims + .iter() + .map(|claim| repository.verify(claim, &snapshot.tree_sha256)) + .collect::>(); + bounded_receipt(ClaimVerificationReceipt { + verifier: VERIFIER_VERSION.to_string(), + head_sha: Some(snapshot.head_sha), + tree_sha256: Some(snapshot.tree_sha256), + state: ClaimVerificationState::Complete, + claims: verified, + }) + .unwrap_or_else(|| exhausted_receipt(Some(&fallback_head_sha), claims)) +} + +pub(crate) fn unavailable_receipt( + head_sha: Option<&str>, + claims: &[MachineClaim], +) -> ClaimVerificationReceipt { + state_receipt(ClaimVerificationState::Unavailable, head_sha, claims) +} + +pub(crate) fn exhausted_receipt( + head_sha: Option<&str>, + claims: &[MachineClaim], +) -> ClaimVerificationReceipt { + state_receipt(ClaimVerificationState::Exhausted, head_sha, claims) +} + +fn state_receipt( + state: ClaimVerificationState, + head_sha: Option<&str>, + claims: &[MachineClaim], +) -> ClaimVerificationReceipt { + let claims = claims + .iter() + .take(MAX_CLAIMS) + .map(|claim| VerifiedMachineClaim { + claim_input_sha256: claim_input_sha256(claim), + verdict: ClaimVerificationVerdict::Unresolved, + evidence: Vec::new(), + }) + .collect(); + ClaimVerificationReceipt { + verifier: VERIFIER_VERSION.to_string(), + head_sha: head_sha + .filter(|value| crate::repository_search::valid_full_object_id(value)) + .map(|value| value.to_ascii_lowercase()), + tree_sha256: None, + state, + claims, + } +} + +fn bounded_receipt(receipt: ClaimVerificationReceipt) -> Option { + (serde_json::to_vec(&receipt).ok()?.len() <= MAX_RECEIPT_BYTES).then_some(receipt) +} + +#[derive(Debug, Clone)] +struct NormalizedClaim { + kind: MachineClaimKind, + path: String, + symbol: String, + expected_signature: Option, +} + +fn normalize_claim(claim: &MachineClaim) -> Option { + let path = claim.path.trim(); + let symbol = claim.symbol.trim(); + if path != claim.path + || symbol != claim.symbol + || path.len() > MAX_PATH_BYTES + || symbol.len() > MAX_SYMBOL_BYTES + || !valid_rust_path(path) + || !valid_qualified_symbol(symbol) + { + return None; + } + let module = module_for_path(path)?; + if symbol != module && !symbol.starts_with(&format!("{module}::")) { + return None; + } + let expected_signature = match claim.kind { + MachineClaimKind::SignatureMismatch => { + Some(normalize_signature(claim.expected_signature.as_ref()?)?) + } + MachineClaimKind::RustCopyMoveOut | MachineClaimKind::SymbolAbsent => { + if claim.expected_signature.is_some() { + return None; + } + None + } + }; + Some(NormalizedClaim { + kind: claim.kind, + path: path.to_string(), + symbol: symbol.to_string(), + expected_signature, + }) +} + +fn normalize_signature(signature: &MachineSignature) -> Option { + if signature.parameters.len() > MAX_SIGNATURE_PARAMETERS { + return None; + } + let mut total = signature.returns.len(); + let mut parameters = Vec::with_capacity(signature.parameters.len()); + for parameter in &signature.parameters { + total = total.checked_add(parameter.len())?; + if parameter.len() > MAX_SIGNATURE_TYPE_BYTES || total > MAX_SIGNATURE_TOTAL_BYTES { + return None; + } + parameters.push(canonical_type(&syn::parse_str::(parameter).ok()?)?); + } + if signature.returns.len() > MAX_SIGNATURE_TYPE_BYTES || total > MAX_SIGNATURE_TOTAL_BYTES { + return None; + } + let returns = canonical_type(&syn::parse_str::(&signature.returns).ok()?)?; + Some(MachineSignature { + receiver: signature.receiver, + parameters, + returns, + is_async: signature.is_async, + is_unsafe: signature.is_unsafe, + }) +} + +fn claim_input_sha256(claim: &MachineClaim) -> String { + let bytes = normalize_claim(claim) + .and_then(|claim| serde_json::to_vec(&CanonicalClaim::from(claim)).ok()) + .or_else(|| serde_json::to_vec(claim).ok()) + .unwrap_or_default(); + sha256_hex(&bytes) +} + +pub(crate) fn claim_identity_sha256(claim: &MachineClaim) -> String { + claim_input_sha256(claim) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct CanonicalClaim { + kind: MachineClaimKind, + path: String, + symbol: String, + #[serde(skip_serializing_if = "Option::is_none")] + expected_signature: Option, +} + +impl From for CanonicalClaim { + fn from(claim: NormalizedClaim) -> Self { + Self { + kind: claim.kind, + path: claim.path, + symbol: claim.symbol, + expected_signature: claim.expected_signature, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SymbolKind { + DataType, + Callable, + Other, +} + +#[derive(Debug, Clone)] +struct SymbolRecord { + symbol: String, + path: String, + source_sha256: String, + fingerprint: String, + kind: SymbolKind, + signature: Option, + copy_derive_present: bool, + conditional: bool, + generic: bool, +} + +impl SymbolRecord { + fn evidence(&self, role: ClaimEvidenceRole) -> ClaimVerificationEvidence { + let role_text = format!("{role:?}"); + ClaimVerificationEvidence { + role, + path_sha256: sha256_hex(self.path.as_bytes()), + source_sha256: self.source_sha256.clone(), + span_sha256: sha256_hex( + format!( + "{}\0{}\0{}\0{}", + self.path, self.symbol, role_text, self.fingerprint + ) + .as_bytes(), + ), + } + } +} + +struct SyntaxWorkBudget { + remaining: usize, + exhausted: bool, + block_depth: usize, + nested_impl_in_block: bool, + unexpanded_macro_in_block: bool, +} + +impl SyntaxWorkBudget { + fn new(limit: usize) -> Self { + Self { + remaining: limit, + exhausted: false, + block_depth: 0, + nested_impl_in_block: false, + unexpanded_macro_in_block: false, + } + } + + fn consume(&mut self) -> bool { + if self.remaining == 0 { + self.exhausted = true; + return false; + } + self.remaining -= 1; + true + } +} + +impl<'ast> Visit<'ast> for SyntaxWorkBudget { + fn visit_attribute(&mut self, node: &'ast Attribute) { + if self.consume() { + syn::visit::visit_attribute(self, node); + } + } + + fn visit_expr(&mut self, node: &'ast syn::Expr) { + if self.consume() { + syn::visit::visit_expr(self, node); + } + } + + fn visit_impl_item(&mut self, node: &'ast syn::ImplItem) { + if self.consume() { + syn::visit::visit_impl_item(self, node); + } + } + + fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + if self.block_depth > 0 { + self.nested_impl_in_block = true; + } + if self.consume() { + syn::visit::visit_item_impl(self, node); + } + } + + fn visit_macro(&mut self, node: &'ast syn::Macro) { + if self.block_depth > 0 { + self.unexpanded_macro_in_block = true; + } + if self.consume() { + syn::visit::visit_macro(self, node); + } + } + + fn visit_item(&mut self, node: &'ast Item) { + if self.consume() { + syn::visit::visit_item(self, node); + } + } + + fn visit_block(&mut self, node: &'ast syn::Block) { + if self.consume() { + self.block_depth += 1; + syn::visit::visit_block(self, node); + self.block_depth -= 1; + } + } + + fn visit_trait_item(&mut self, node: &'ast syn::TraitItem) { + if self.consume() { + syn::visit::visit_trait_item(self, node); + } + } + + fn visit_type(&mut self, node: &'ast Type) { + if self.consume() { + syn::visit::visit_type(self, node); + } + } +} + +#[derive(Default)] +struct ParsedRepository { + symbols: BTreeMap>, + copy_implementations: BTreeMap>, + possibly_aliased_copy_implementations: BTreeSet, + ambiguous_trait_impl_names: BTreeSet, + trait_implementation_targets: Vec, + trait_implementation_target_incomplete: bool, + macro_modules: BTreeSet, + crate_root_macro_namespace_uncertain: bool, + uncertain_namespace_modules: BTreeSet, + unexpanded_macro_in_block: bool, + parse_incomplete: bool, + proven_modules: BTreeSet, + source_scopes: BTreeMap, + module_sources: BTreeMap>, + reachable_paths: BTreeMap, + module_declarations: BTreeSet, +} + +struct ParsedSourceFile { + source_sha256: String, + source_len: usize, + parsed: Option, +} + +impl ParsedRepository { + fn parse(files: &[MachineSourceFile]) -> Option { + let mut repository = Self::default(); + let mut syntax_work = SyntaxWorkBudget::new(MAX_SYNTAX_NODES); + let mut parsed_files = BTreeMap::new(); + for file in files { + parsed_files.insert( + file.path.clone(), + ParsedSourceFile { + source_sha256: sha256_hex(file.source.as_bytes()), + source_len: file.source.len(), + parsed: syn::parse_file(&file.source).ok(), + }, + ); + } + + let roots = ["src/lib.rs", "src/main.rs"] + .into_iter() + .filter(|path| parsed_files.contains_key(*path)) + .collect::>(); + if roots.len() != 1 { + repository.parse_incomplete = true; + return Some(repository); + } + repository.collect_reachable_file( + roots[0], + "crate", + "src", + &parsed_files, + &mut syntax_work, + ); + if syntax_work.exhausted { + return None; + } + Some(repository) + } + + fn collect_reachable_file( + &mut self, + path: &str, + module: &str, + child_directory: &str, + files: &BTreeMap, + syntax_work: &mut SyntaxWorkBudget, + ) { + if let Some(existing_module) = self.reachable_paths.get(path) { + if existing_module != module { + self.parse_incomplete = true; + } + return; + } + if self + .module_sources + .get(module) + .is_some_and(|paths| !paths.is_empty()) + { + self.parse_incomplete = true; + return; + } + let Some(file) = files.get(path) else { + self.parse_incomplete = true; + return; + }; + self.reachable_paths + .insert(path.to_string(), module.to_string()); + self.module_sources + .insert(module.to_string(), vec![path.to_string()]); + self.proven_modules.insert(module.to_string()); + self.source_scopes.insert( + path.to_string(), + SymbolRecord { + symbol: module.to_string(), + path: path.to_string(), + source_sha256: file.source_sha256.clone(), + fingerprint: format!("source:{}", file.source_len), + kind: SymbolKind::Other, + signature: None, + copy_derive_present: false, + conditional: false, + generic: false, + }, + ); + let Some(parsed) = file.parsed.as_ref() else { + self.parse_incomplete = true; + return; + }; + syntax_work.visit_file(parsed); + if syntax_work.exhausted { + return; + } + if syntax_work.nested_impl_in_block { + // Rust permits an implementation item in a block. It can change + // whether a type implements Copy without being part of the + // module-level index, so no claim is certifiable from this tree. + self.parse_incomplete = true; + } + if syntax_work.unexpanded_macro_in_block { + // A block-local macro can expand to a non-local impl. Keep module + // absence proofs available, but do not certify Copy or signature + // claims from an index that cannot observe the expansion. The + // expansion can also export a macro into the crate root. + self.unexpanded_macro_in_block = true; + self.crate_root_macro_namespace_uncertain = true; + } + if attrs_are_conditional_or_expansive(&parsed.attrs) { + self.parse_incomplete = true; + return; + } + self.collect_items(&parsed.items, module, path, &file.source_sha256, false); + self.collect_module_graph( + &parsed.items, + module, + path, + child_directory, + files, + syntax_work, + ); + } + + fn collect_module_graph( + &mut self, + items: &[Item], + module: &str, + source_path: &str, + child_directory: &str, + files: &BTreeMap, + syntax_work: &mut SyntaxWorkBudget, + ) { + for item in items { + let Item::Mod(item) = item else { + continue; + }; + let child_module = qualified(module, &item.ident); + if !self.module_declarations.insert(child_module.clone()) { + self.parse_incomplete = true; + continue; + } + if module_mapping_is_uncertain(&item.attrs) { + self.parse_incomplete = true; + continue; + } + let child_directory = format!("{child_directory}/{}", item.ident); + if let Some((_, inline_items)) = &item.content { + self.proven_modules.insert(child_module.clone()); + self.module_sources + .insert(child_module.clone(), vec![source_path.to_string()]); + self.collect_module_graph( + inline_items, + &child_module, + source_path, + &child_directory, + files, + syntax_work, + ); + continue; + } + + let flat_path = format!("{child_directory}.rs"); + let directory_path = format!("{child_directory}/mod.rs"); + let candidates = [flat_path.as_str(), directory_path.as_str()] + .into_iter() + .filter(|path| files.contains_key(*path)) + .collect::>(); + if candidates.len() != 1 { + self.parse_incomplete = true; + continue; + } + self.collect_reachable_file( + candidates[0], + &child_module, + &child_directory, + files, + syntax_work, + ); + } + } + + fn collect_items( + &mut self, + items: &[Item], + module: &str, + path: &str, + source_sha256: &str, + inherited_conditional: bool, + ) { + for item in items { + if item_attrs_can_expand_siblings(item) { + self.uncertain_namespace_modules.insert(module.to_string()); + self.crate_root_macro_namespace_uncertain = true; + } + match item { + Item::Struct(item) => self.record_type( + module, + &item.ident, + &item.attrs, + !item.generics.params.is_empty(), + "struct", + path, + source_sha256, + inherited_conditional, + ), + Item::Enum(item) => self.record_type( + module, + &item.ident, + &item.attrs, + !item.generics.params.is_empty(), + "enum", + path, + source_sha256, + inherited_conditional, + ), + Item::Union(item) => self.record_type( + module, + &item.ident, + &item.attrs, + !item.generics.params.is_empty(), + "union", + path, + source_sha256, + inherited_conditional, + ), + Item::Type(item) => self.record_named( + qualified(module, &item.ident), + path, + source_sha256, + "type", + SymbolKind::Other, + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + Item::Fn(item) => self.record_callable( + qualified(module, &item.sig.ident), + &item.sig, + &item.attrs, + path, + source_sha256, + inherited_conditional, + ), + Item::Const(item) => self.record_named( + qualified(module, &item.ident), + path, + source_sha256, + "const", + SymbolKind::Other, + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + Item::Static(item) => self.record_named( + qualified(module, &item.ident), + path, + source_sha256, + "static", + SymbolKind::Other, + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + Item::Trait(item) => { + let trait_symbol = qualified(module, &item.ident); + let conditional = inherited_conditional + || attrs_are_conditional_or_expansive(&item.attrs) + || !item.generics.params.is_empty(); + self.record_named( + trait_symbol.clone(), + path, + source_sha256, + "trait", + SymbolKind::Other, + conditional, + ); + for trait_item in &item.items { + match trait_item { + syn::TraitItem::Fn(method) => self.record_callable( + qualified(&trait_symbol, &method.sig.ident), + &method.sig, + &method.attrs, + path, + source_sha256, + conditional, + ), + syn::TraitItem::Const(item) => self.record_named( + qualified(&trait_symbol, &item.ident), + path, + source_sha256, + "trait-const", + SymbolKind::Other, + conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + syn::TraitItem::Type(item) => self.record_named( + qualified(&trait_symbol, &item.ident), + path, + source_sha256, + "trait-type", + SymbolKind::Other, + conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + syn::TraitItem::Macro(_) => { + self.macro_modules.insert(trait_symbol.clone()); + } + syn::TraitItem::Verbatim(_) => self.parse_incomplete = true, + _ => {} + } + } + } + Item::TraitAlias(item) => self.record_named( + qualified(module, &item.ident), + path, + source_sha256, + "trait-alias", + SymbolKind::Other, + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + Item::Mod(item) => { + let symbol = qualified(module, &item.ident); + let conditional = + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs); + self.record_named( + symbol.clone(), + path, + source_sha256, + "module", + SymbolKind::Other, + conditional, + ); + if let Some((_, items)) = &item.content { + self.collect_items(items, &symbol, path, source_sha256, conditional); + } + } + Item::Impl(item) => { + if item.trait_.is_some() + && let Some(name) = unqualified_type_name(item.self_ty.as_ref()) + { + self.ambiguous_trait_impl_names.insert(name); + } + let Some(target) = resolve_type_path(module, item.self_ty.as_ref()) else { + if item.trait_.is_some() { + self.trait_implementation_target_incomplete = true; + } + continue; + }; + if item.trait_.is_some() { + self.trait_implementation_targets.push(target.clone()); + } + let conditional = inherited_conditional + || attrs_are_conditional_or_expansive(&item.attrs) + || !item.generics.params.is_empty(); + if let Some((_, trait_path, _)) = item.trait_.as_ref() + && path_is_explicit_copy_trait(trait_path) + { + self.copy_implementations + .entry(target.clone()) + .or_default() + .push(SymbolRecord { + symbol: target.clone(), + path: path.to_string(), + source_sha256: source_sha256.to_string(), + fingerprint: format!("impl-copy:{target}"), + kind: SymbolKind::DataType, + signature: None, + copy_derive_present: false, + conditional, + generic: !item.generics.params.is_empty(), + }); + } else if item.trait_.is_some() { + // Without name resolution, any other trait path can be + // an import or re-export alias for `Copy`. + self.possibly_aliased_copy_implementations + .insert(target.clone()); + } + for impl_item in &item.items { + match impl_item { + syn::ImplItem::Fn(method) => self.record_callable( + qualified(&target, &method.sig.ident), + &method.sig, + &method.attrs, + path, + source_sha256, + conditional, + ), + syn::ImplItem::Const(item) => self.record_named( + qualified(&target, &item.ident), + path, + source_sha256, + "impl-const", + SymbolKind::Other, + conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + syn::ImplItem::Type(item) => self.record_named( + qualified(&target, &item.ident), + path, + source_sha256, + "impl-type", + SymbolKind::Other, + conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + syn::ImplItem::Macro(_) => { + self.macro_modules.insert(target.clone()); + } + syn::ImplItem::Verbatim(_) => self.parse_incomplete = true, + _ => {} + } + } + } + Item::Use(item) => self.record_use_tree( + module, + &item.tree, + &[], + path, + source_sha256, + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + Item::ExternCrate(item) => self.record_named( + qualified( + module, + item.rename + .as_ref() + .map_or(&item.ident, |(_, rename)| rename), + ), + path, + source_sha256, + "extern-crate", + SymbolKind::Other, + inherited_conditional || attrs_are_conditional_or_expansive(&item.attrs), + ), + Item::Macro(item) => { + if let Some(ident) = item.ident.as_ref() { + if item + .attrs + .iter() + .any(|attribute| attribute.path().is_ident("macro_export")) + { + self.crate_root_macro_namespace_uncertain = true; + } + self.record_named( + qualified(module, ident), + path, + source_sha256, + "macro", + SymbolKind::Other, + inherited_conditional + || attrs_are_conditional_or_expansive(&item.attrs), + ); + } else { + self.macro_modules.insert(module.to_string()); + self.crate_root_macro_namespace_uncertain = true; + } + } + Item::ForeignMod(_) => { + self.uncertain_namespace_modules.insert(module.to_string()); + } + Item::Verbatim(_) => self.parse_incomplete = true, + _ => {} + } + } + } + + #[allow(clippy::too_many_arguments)] + fn record_type( + &mut self, + module: &str, + ident: &syn::Ident, + attrs: &[Attribute], + generic: bool, + item_kind: &str, + path: &str, + source_sha256: &str, + inherited_conditional: bool, + ) { + let symbol = qualified(module, ident); + let copy_derive_present = derives_copy(attrs); + self.symbols + .entry(symbol.clone()) + .or_default() + .push(SymbolRecord { + symbol, + path: path.to_string(), + source_sha256: source_sha256.to_string(), + fingerprint: format!( + "{item_kind}:{}:copy-derive={copy_derive_present}:generic={generic}", + ident + ), + kind: SymbolKind::DataType, + signature: None, + copy_derive_present, + conditional: inherited_conditional || attrs_are_conditional_or_expansive(attrs), + generic, + }); + } + + fn record_named( + &mut self, + symbol: String, + path: &str, + source_sha256: &str, + item_kind: &str, + kind: SymbolKind, + conditional: bool, + ) { + self.symbols + .entry(symbol.clone()) + .or_default() + .push(SymbolRecord { + fingerprint: format!("{item_kind}:{symbol}"), + symbol, + path: path.to_string(), + source_sha256: source_sha256.to_string(), + kind, + signature: None, + copy_derive_present: false, + conditional, + generic: false, + }); + } + + fn record_callable( + &mut self, + symbol: String, + signature: &syn::Signature, + attrs: &[Attribute], + path: &str, + source_sha256: &str, + inherited_conditional: bool, + ) { + let normalized = canonical_signature(signature); + self.symbols + .entry(symbol.clone()) + .or_default() + .push(SymbolRecord { + fingerprint: normalized + .as_ref() + .and_then(|value| serde_json::to_string(value).ok()) + .unwrap_or_else(|| format!("unsupported-signature:{symbol}")), + symbol, + path: path.to_string(), + source_sha256: source_sha256.to_string(), + kind: SymbolKind::Callable, + signature: normalized, + copy_derive_present: false, + conditional: inherited_conditional || attrs_are_conditional_or_expansive(attrs), + generic: !signature.generics.params.is_empty(), + }); + } + + #[allow(clippy::too_many_arguments)] + fn record_use_tree( + &mut self, + module: &str, + tree: &syn::UseTree, + prefix: &[String], + path: &str, + source_sha256: &str, + conditional: bool, + ) { + match tree { + syn::UseTree::Path(value) => { + let mut nested_prefix = prefix.to_vec(); + nested_prefix.push(value.ident.to_string()); + self.record_use_tree( + module, + value.tree.as_ref(), + &nested_prefix, + path, + source_sha256, + conditional, + ); + } + syn::UseTree::Name(value) => { + let local = if value.ident == "self" { + prefix.last().cloned() + } else { + Some(value.ident.to_string()) + }; + if let Some(local) = local { + self.record_named( + format!("{module}::{local}"), + path, + source_sha256, + "use", + SymbolKind::Other, + conditional, + ); + } + } + syn::UseTree::Rename(value) => self.record_named( + qualified(module, &value.rename), + path, + source_sha256, + "use-rename", + SymbolKind::Other, + conditional, + ), + syn::UseTree::Glob(_) => { + self.uncertain_namespace_modules.insert(module.to_string()); + } + syn::UseTree::Group(value) => { + for item in &value.items { + self.record_use_tree(module, item, prefix, path, source_sha256, conditional); + } + } + } + } + + fn verify(&self, claim: &MachineClaim, tree_sha256: &str) -> VerifiedMachineClaim { + let hash = claim_input_sha256(claim); + let Some(claim) = normalize_claim(claim) else { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + }; + if self.parse_incomplete { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + } + if !self.claim_scope_is_unique(&claim) { + return proof(hash, ClaimVerificationVerdict::Unresolved, Vec::new()); + } + match claim.kind { + MachineClaimKind::RustCopyMoveOut => self.verify_copy_move_out(hash, &claim), + MachineClaimKind::SymbolAbsent => self.verify_symbol_absent(hash, &claim, tree_sha256), + MachineClaimKind::SignatureMismatch => self.verify_signature(hash, &claim), + } + } + + fn verify_copy_move_out(&self, hash: String, claim: &NormalizedClaim) -> VerifiedMachineClaim { + let candidates = self + .symbols + .get(&claim.symbol) + .map(Vec::as_slice) + .unwrap_or(&[]); + if candidates.len() != 1 || candidates[0].path != claim.path { + return proof(hash, ClaimVerificationVerdict::Unresolved, Vec::new()); + } + let candidate = &candidates[0]; + if candidate.kind != SymbolKind::DataType + || candidate.conditional + || candidate.generic + || self.unexpanded_macro_in_block + || self.external_root_shadowing_possible() + || self.symbol_has_conditional_ancestor(&claim.symbol) + { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + } + if candidate.copy_derive_present { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + } + let implementations = self + .copy_implementations + .get(&claim.symbol) + .map(Vec::as_slice) + .unwrap_or(&[]); + if implementations.len() == 1 + && !implementations[0].conditional + && !implementations[0].generic + { + return proof( + hash, + ClaimVerificationVerdict::Refuted, + vec![implementations[0].evidence(ClaimEvidenceRole::CopyImplementation)], + ); + } + if implementations.len() > 1 { + return proof(hash, ClaimVerificationVerdict::Unresolved, Vec::new()); + } + if implementations.len() == 1 + || self + .possibly_aliased_copy_implementations + .contains(&claim.symbol) + || claim + .symbol + .rsplit("::") + .next() + .is_some_and(|name| self.ambiguous_trait_impl_names.contains(name)) + || self.trait_implementation_target_may_be_an_alias() + || !self.macro_modules.is_empty() + { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + } + proof( + hash, + ClaimVerificationVerdict::Supported, + vec![candidate.evidence(ClaimEvidenceRole::SymbolDefinition)], + ) + } + + fn verify_symbol_absent( + &self, + hash: String, + claim: &NormalizedClaim, + tree_sha256: &str, + ) -> VerifiedMachineClaim { + let candidates = self + .symbols + .get(&claim.symbol) + .map(Vec::as_slice) + .unwrap_or(&[]); + if candidates.len() == 1 && candidates[0].path == claim.path && !candidates[0].conditional { + return proof( + hash, + ClaimVerificationVerdict::Refuted, + vec![candidates[0].evidence(ClaimEvidenceRole::SymbolDefinition)], + ); + } + if !candidates.is_empty() { + return proof(hash, ClaimVerificationVerdict::Unresolved, Vec::new()); + } + let target_module = claim + .symbol + .rsplit_once("::") + .map_or("crate", |(module, _)| module); + if !self.proven_modules.contains(target_module) + || !self + .module_sources + .get(target_module) + .is_some_and(|paths| paths.as_slice() == [claim.path.as_str()]) + || self.symbol_has_conditional_ancestor(&claim.symbol) + || self.namespace_is_uncertain(target_module) + || (target_module == "crate" && self.crate_root_macro_namespace_uncertain) + || self.macro_modules.iter().any(|module| { + target_module == module || target_module.starts_with(&format!("{module}::")) + }) + { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + } + let Some(scope) = self.source_scopes.get(&claim.path) else { + return proof(hash, ClaimVerificationVerdict::Unresolved, Vec::new()); + }; + let mut evidence = scope.evidence(ClaimEvidenceRole::SourceScope); + evidence.span_sha256 = sha256_hex( + format!( + "{}\0{}\0{}", + evidence.span_sha256, claim.symbol, tree_sha256 + ) + .as_bytes(), + ); + proof(hash, ClaimVerificationVerdict::Supported, vec![evidence]) + } + + fn verify_signature(&self, hash: String, claim: &NormalizedClaim) -> VerifiedMachineClaim { + let candidates = self + .symbols + .get(&claim.symbol) + .map(Vec::as_slice) + .unwrap_or(&[]); + if candidates.len() != 1 || candidates[0].path != claim.path { + return proof(hash, ClaimVerificationVerdict::Unresolved, Vec::new()); + } + let candidate = &candidates[0]; + if candidate.kind != SymbolKind::Callable + || candidate.conditional + || candidate.generic + || candidate.signature.is_none() + || self.parse_incomplete + || self.symbol_has_conditional_ancestor(&claim.symbol) + || self.namespace_is_uncertain(&claim.symbol) + || self.unexpanded_macro_in_block + || !self.macro_modules.is_empty() + { + return proof(hash, ClaimVerificationVerdict::Unsupported, Vec::new()); + } + let candidate_signature = candidate.signature.as_ref().expect("checked above"); + let expected_signature = claim.expected_signature.as_ref().expect("normalized claim"); + let signatures_match = candidate_signature == expected_signature + || (!self.external_root_shadowing_possible() + && signatures_equal_ignoring_external_root_colon( + candidate_signature, + expected_signature, + )); + let verdict = if signatures_match { + ClaimVerificationVerdict::Refuted + } else if signatures_are_provably_distinct( + candidate_signature, + expected_signature, + !self.primitive_type_shadowing_possible(), + ) { + ClaimVerificationVerdict::Supported + } else { + // A path can name a type alias or re-export. This verifier does + // not perform Rust name resolution, so only differences that are + // independent of path resolution can certify a mismatch. + ClaimVerificationVerdict::Unsupported + }; + proof( + hash, + verdict, + vec![candidate.evidence(ClaimEvidenceRole::Signature)], + ) + } + + fn symbol_has_conditional_ancestor(&self, symbol: &str) -> bool { + let segments = symbol.split("::").collect::>(); + (2..segments.len()).any(|length| { + self.symbols + .get(&segments[..length].join("::")) + .is_some_and(|records| records.iter().any(|record| record.conditional)) + }) + } + + fn namespace_is_uncertain(&self, symbol_or_module: &str) -> bool { + let segments = symbol_or_module.split("::").collect::>(); + (1..=segments.len()).any(|length| { + self.uncertain_namespace_modules + .contains(&segments[..length].join("::")) + }) + } + + fn claim_scope_is_unique(&self, claim: &NormalizedClaim) -> bool { + let Some(module) = module_for_path(&claim.path) else { + return false; + }; + self.module_sources + .get(&module) + .is_some_and(|paths| paths.as_slice() == [claim.path.as_str()]) + } + + fn trait_implementation_target_may_be_an_alias(&self) -> bool { + self.trait_implementation_target_incomplete + || self.trait_implementation_targets.iter().any(|target| { + !self.symbols.get(target).is_some_and(|records| { + records.len() == 1 && records[0].kind == SymbolKind::DataType + }) + }) + } + + fn external_root_shadowing_possible(&self) -> bool { + self.symbols.keys().any(|symbol| { + symbol.rsplit("::").next().is_some_and(|name| { + matches!(name.strip_prefix("r#").unwrap_or(name), "std" | "core") + }) + }) + } + + fn primitive_type_shadowing_possible(&self) -> bool { + self.symbols.keys().any(|symbol| { + symbol + .rsplit("::") + .next() + .is_some_and(|name| primitive_type_name(name.strip_prefix("r#").unwrap_or(name))) + }) + } +} + +fn proof( + claim_input_sha256: String, + verdict: ClaimVerificationVerdict, + mut evidence: Vec, +) -> VerifiedMachineClaim { + evidence.truncate(MAX_EVIDENCE_PER_CLAIM); + VerifiedMachineClaim { + claim_input_sha256, + verdict, + evidence, + } +} + +fn canonical_signature(signature: &syn::Signature) -> Option { + if signature.constness.is_some() + || signature.abi.is_some() + || signature.variadic.is_some() + || !signature.generics.params.is_empty() + || signature.generics.where_clause.is_some() + || signature.inputs.len() > MAX_SIGNATURE_PARAMETERS + 1 + { + return None; + } + let mut receiver = MachineReceiver::None; + let mut parameters = Vec::new(); + for argument in &signature.inputs { + match argument { + FnArg::Receiver(value) => { + if receiver != MachineReceiver::None + || value.colon_token.is_some() + || value + .reference + .as_ref() + .is_some_and(|(_, lifetime)| lifetime.is_some()) + { + return None; + } + receiver = match (value.reference.is_some(), value.mutability.is_some()) { + (true, false) => MachineReceiver::Shared, + (true, true) => MachineReceiver::Mutable, + (false, _) => MachineReceiver::Value, + }; + } + FnArg::Typed(value) => parameters.push(canonical_type(value.ty.as_ref())?), + } + } + let returns = match &signature.output { + ReturnType::Default => "()".to_string(), + ReturnType::Type(_, value) => canonical_type(value.as_ref())?, + }; + Some(MachineSignature { + receiver, + parameters, + returns, + is_async: signature.asyncness.is_some(), + is_unsafe: signature.unsafety.is_some(), + }) +} + +fn canonical_type(value: &Type) -> Option { + canonical_type_with_root_spelling(value, true) +} + +fn canonical_type_with_root_spelling( + value: &Type, + preserve_external_root_colon: bool, +) -> Option { + match value { + Type::Path(value) if value.qself.is_none() => { + if !resolution_free_type_path(&value.path) { + return None; + } + let mut output = String::new(); + if preserve_external_root_colon && value.path.leading_colon.is_some() { + output.push_str("::"); + } + for (index, segment) in value.path.segments.iter().enumerate() { + if index > 0 { + output.push_str("::"); + } + output.push_str(&segment.ident.to_string()); + match &segment.arguments { + PathArguments::None => {} + PathArguments::AngleBracketed(arguments) => { + let mut types = Vec::new(); + for argument in &arguments.args { + match argument { + GenericArgument::Type(value) => { + types.push(canonical_type_with_root_spelling( + value, + preserve_external_root_colon, + )?) + } + _ => return None, + } + } + output.push('<'); + output.push_str(&types.join(",")); + output.push('>'); + } + PathArguments::Parenthesized(_) => return None, + } + } + Some(output) + } + Type::Reference(value) if value.lifetime.is_none() => { + let mut output = if value.mutability.is_some() { + "&mut " + } else { + "&" + } + .to_string(); + output.push_str(&canonical_type_with_root_spelling( + value.elem.as_ref(), + preserve_external_root_colon, + )?); + Some(output) + } + Type::Tuple(value) => Some(format!( + "({}{})", + value + .elems + .iter() + .map(|value| { + canonical_type_with_root_spelling(value, preserve_external_root_colon) + }) + .collect::>>()? + .join(","), + if value.elems.len() == 1 { "," } else { "" } + )), + Type::Slice(value) => Some(format!( + "[{}]", + canonical_type_with_root_spelling(value.elem.as_ref(), preserve_external_root_colon,)?, + )), + Type::Paren(value) => { + canonical_type_with_root_spelling(value.elem.as_ref(), preserve_external_root_colon) + } + Type::Group(value) => { + canonical_type_with_root_spelling(value.elem.as_ref(), preserve_external_root_colon) + } + Type::Never(_) => Some("!".to_string()), + _ => None, + } +} + +fn signatures_equal_ignoring_external_root_colon( + actual: &MachineSignature, + expected: &MachineSignature, +) -> bool { + actual.receiver == expected.receiver + && actual.is_async == expected.is_async + && actual.is_unsafe == expected.is_unsafe + && actual.parameters.len() == expected.parameters.len() + && actual + .parameters + .iter() + .zip(&expected.parameters) + .all(|(actual, expected)| canonical_types_equal_without_root_colon(actual, expected)) + && canonical_types_equal_without_root_colon(&actual.returns, &expected.returns) +} + +fn canonical_type_ignoring_external_root_colon(value: &str) -> Option { + canonical_type_with_root_spelling(&syn::parse_str::(value).ok()?, false) +} + +fn canonical_types_equal_without_root_colon(actual: &str, expected: &str) -> bool { + match ( + canonical_type_ignoring_external_root_colon(actual), + canonical_type_ignoring_external_root_colon(expected), + ) { + (Some(actual), Some(expected)) => actual == expected, + _ => false, + } +} + +fn signatures_are_provably_distinct( + actual: &MachineSignature, + expected: &MachineSignature, + primitive_names_are_resolution_free: bool, +) -> bool { + actual.receiver != expected.receiver + || actual.is_async != expected.is_async + || actual.is_unsafe != expected.is_unsafe + || actual.parameters.len() != expected.parameters.len() + || actual + .parameters + .iter() + .zip(&expected.parameters) + .any(|(actual, expected)| { + canonical_types_are_provably_distinct( + actual, + expected, + primitive_names_are_resolution_free, + ) + }) + || canonical_types_are_provably_distinct( + &actual.returns, + &expected.returns, + primitive_names_are_resolution_free, + ) +} + +fn canonical_types_are_provably_distinct( + actual: &str, + expected: &str, + primitive_names_are_resolution_free: bool, +) -> bool { + let Ok(actual) = syn::parse_str::(actual) else { + return false; + }; + let Ok(expected) = syn::parse_str::(expected) else { + return false; + }; + types_are_provably_distinct(&actual, &expected, primitive_names_are_resolution_free) +} + +fn types_are_provably_distinct( + actual: &Type, + expected: &Type, + primitive_names_are_resolution_free: bool, +) -> bool { + match (actual, expected) { + (Type::Path(actual), Type::Path(expected)) + if actual.qself.is_none() + && expected.qself.is_none() + && primitive_names_are_resolution_free + && is_unqualified_primitive_path(&actual.path) + && is_unqualified_primitive_path(&expected.path) => + { + actual.path.segments[0].ident != expected.path.segments[0].ident + } + (Type::Reference(actual), Type::Reference(expected)) => { + actual.mutability.is_some() != expected.mutability.is_some() + || types_are_provably_distinct( + actual.elem.as_ref(), + expected.elem.as_ref(), + primitive_names_are_resolution_free, + ) + } + (Type::Tuple(actual), Type::Tuple(expected)) => { + actual.elems.len() != expected.elems.len() + || actual + .elems + .iter() + .zip(&expected.elems) + .any(|(actual, expected)| { + types_are_provably_distinct( + actual, + expected, + primitive_names_are_resolution_free, + ) + }) + } + (Type::Slice(actual), Type::Slice(expected)) => types_are_provably_distinct( + actual.elem.as_ref(), + expected.elem.as_ref(), + primitive_names_are_resolution_free, + ), + (Type::Never(_), Type::Never(_)) => false, + (Type::Path(actual), _) | (_, Type::Path(actual)) + if !primitive_names_are_resolution_free + || !is_unqualified_primitive_path(&actual.path) => + { + // An opaque named type can resolve to an alias of any other + // accepted form, including a path through a re-export. + false + } + (Type::Never(_), _) | (_, Type::Never(_)) => true, + (Type::Reference(_), _) | (_, Type::Reference(_)) => true, + (Type::Tuple(_), _) | (_, Type::Tuple(_)) => true, + (Type::Slice(_), _) | (_, Type::Slice(_)) => true, + _ => false, + } +} + +fn is_unqualified_primitive_path(path: &syn::Path) -> bool { + path.leading_colon.is_none() + && path.segments.len() == 1 + && matches!(path.segments[0].arguments, PathArguments::None) + && primitive_type_name(&path.segments[0].ident.to_string()) +} + +fn primitive_type_name(name: &str) -> bool { + matches!( + name, + "bool" + | "char" + | "str" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + ) +} + +fn resolution_free_type_path(path: &syn::Path) -> bool { + let mut segments = path.segments.iter(); + let Some(first) = segments.next() else { + return false; + }; + let remaining = segments.count(); + if remaining == 0 { + return is_unqualified_primitive_path(path); + } + matches!(first.arguments, PathArguments::None) + && matches!(first.ident.to_string().as_str(), "crate" | "std" | "core") + && (path.leading_colon.is_none() || first.ident != "crate") +} + +fn derives_copy(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attribute| { + attribute.path().is_ident("derive") + && attribute + .parse_args_with(Punctuated::::parse_terminated) + .is_ok_and(|paths| paths.iter().any(path_names_copy_derive)) + }) +} + +fn attrs_are_conditional_or_expansive(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attribute| { + let path = attribute.path(); + !(path.is_ident("doc") + || path.is_ident("allow") + || path.is_ident("warn") + || path.is_ident("deny") + || path.is_ident("forbid") + || path.is_ident("must_use") + || path.is_ident("non_exhaustive") + || path.is_ident("repr") + || path.is_ident("cold") + || path.is_ident("inline") + || path.is_ident("track_caller")) + }) +} + +fn module_mapping_is_uncertain(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attribute| { + let path = attribute.path(); + path.is_ident("path") + || path.is_ident("cfg") + || path.is_ident("cfg_attr") + || attrs_are_conditional_or_expansive(std::slice::from_ref(attribute)) + }) +} + +fn item_attrs_can_expand_siblings(item: &Item) -> bool { + let attrs = match item { + Item::Const(value) => &value.attrs, + Item::Enum(value) => &value.attrs, + Item::ExternCrate(value) => &value.attrs, + Item::Fn(value) => &value.attrs, + Item::ForeignMod(value) => &value.attrs, + Item::Impl(value) => &value.attrs, + Item::Macro(value) => &value.attrs, + Item::Mod(value) => &value.attrs, + Item::Static(value) => &value.attrs, + Item::Struct(value) => &value.attrs, + Item::Trait(value) => &value.attrs, + Item::TraitAlias(value) => &value.attrs, + Item::Type(value) => &value.attrs, + Item::Union(value) => &value.attrs, + Item::Use(value) => &value.attrs, + Item::Verbatim(_) => return false, + _ => return false, + }; + attrs.iter().any(|attribute| { + let path = attribute.path(); + if path.is_ident("derive") { + return true; + } + !(path.is_ident("cfg") + || path.is_ident("doc") + || path.is_ident("allow") + || path.is_ident("warn") + || path.is_ident("deny") + || path.is_ident("forbid") + || path.is_ident("must_use") + || path.is_ident("non_exhaustive") + || path.is_ident("repr") + || path.is_ident("cold") + || path.is_ident("inline") + || path.is_ident("track_caller") + || path.is_ident("deprecated") + || path.is_ident("test") + || path.is_ident("ignore") + || path.is_ident("should_panic")) + }) +} + +fn path_names_copy_derive(path: &syn::Path) -> bool { + let segments = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>(); + matches!(segments.as_slice(), [copy] if copy == "Copy") + || matches!(segments.as_slice(), [root, marker, copy] + if matches!(root.as_str(), "std" | "core") && marker == "marker" && copy == "Copy") +} + +fn path_is_explicit_copy_trait(path: &syn::Path) -> bool { + if path.leading_colon.is_none() + || path + .segments + .iter() + .any(|segment| !matches!(segment.arguments, PathArguments::None)) + { + return false; + } + let segments = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>(); + matches!(segments.as_slice(), [root, marker, copy] + if matches!(root.as_str(), "std" | "core") && marker == "marker" && copy == "Copy") +} + +fn resolve_type_path(module: &str, value: &Type) -> Option { + let Type::Path(value) = value else { + return None; + }; + if value.qself.is_some() + || value + .path + .segments + .iter() + .any(|segment| !matches!(segment.arguments, PathArguments::None)) + { + return None; + } + let segments = value + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>(); + let first = segments.first()?.as_str(); + match first { + "crate" => Some(segments.join("::")), + "self" => Some( + std::iter::once(module.to_string()) + .chain(segments.into_iter().skip(1)) + .collect::>() + .join("::"), + ), + "super" => { + let mut module_segments = module.split("::").map(str::to_string).collect::>(); + let mut consumed = 0usize; + while segments + .get(consumed) + .is_some_and(|segment| segment == "super") + { + if module_segments.len() <= 1 { + return None; + } + module_segments.pop(); + consumed += 1; + } + module_segments.extend(segments.into_iter().skip(consumed)); + Some(module_segments.join("::")) + } + _ => Some(format!("{module}::{}", segments.join("::"))), + } +} + +fn unqualified_type_name(value: &Type) -> Option { + let Type::Path(value) = value else { + return None; + }; + if value.qself.is_some() + || value.path.leading_colon.is_some() + || value.path.segments.len() != 1 + || !matches!(value.path.segments[0].arguments, PathArguments::None) + { + return None; + } + Some(value.path.segments[0].ident.to_string()) +} + +fn module_for_path(path: &str) -> Option { + if path == "src/lib.rs" || path == "src/main.rs" { + return Some("crate".to_string()); + } + let rest = path.strip_prefix("src/")?.strip_suffix(".rs")?; + if rest.starts_with("bin/") || rest == "bin" { + return None; + } + let rest = rest.strip_suffix("/mod").unwrap_or(rest); + if rest.is_empty() || rest.split('/').any(|segment| !valid_identifier(segment)) { + return None; + } + Some(format!("crate::{}", rest.replace('/', "::"))) +} + +fn qualified(module: &str, ident: &syn::Ident) -> String { + format!("{module}::{ident}") +} + +fn valid_rust_path(path: &str) -> bool { + path.ends_with(".rs") + && path.len() <= MAX_PATH_BYTES + && crate::forge::valid_repository_path(path) +} + +fn valid_qualified_symbol(symbol: &str) -> bool { + let mut segments = symbol.split("::"); + segments.next() == Some("crate") + && segments.clone().count() >= 1 + && segments.all(valid_identifier) +} + +fn valid_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_alphabetic()) + && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn claim(kind: MachineClaimKind, symbol: &str) -> MachineClaim { + MachineClaim { + kind, + path: "src/identity.rs".into(), + symbol: symbol.into(), + expected_signature: None, + } + } + + fn root_claim(kind: MachineClaimKind, symbol: &str) -> MachineClaim { + MachineClaim { + kind, + path: "src/lib.rs".into(), + symbol: symbol.into(), + expected_signature: None, + } + } + + fn snapshot(source: &str) -> MachineSourceSnapshot { + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: source.into(), + }, + ], + } + } + + fn root_snapshot(source: &str) -> MachineSourceSnapshot { + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![MachineSourceFile { + path: "src/lib.rs".into(), + source: source.into(), + }], + } + } + + #[test] + fn copy_derive_is_not_conclusive_without_semantic_proof() { + let receipt = verify_snapshot( + snapshot("#[derive(Debug, Clone, Copy)]\npub struct IdentityFailure(String);\n"), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!(receipt.state, ClaimVerificationState::Complete); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + assert!(receipt.claims[0].evidence.is_empty()); + let serialized = serde_json::to_string(&receipt).unwrap(); + assert!(!serialized.contains("IdentityFailure(String)")); + } + + #[test] + fn concrete_copy_derive_remains_unsupported_without_semantic_proof() { + let receipt = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod llm;\n".into(), + }, + MachineSourceFile { + path: "src/llm.rs".into(), + source: include_str!("llm.rs").into(), + }, + ], + }, + &[MachineClaim { + kind: MachineClaimKind::RustCopyMoveOut, + path: "src/llm.rs".into(), + symbol: "crate::llm::AtomicAttributionIdentityFailure".into(), + expected_signature: None, + }], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn non_copy_type_supports_move_out_claim() { + let receipt = verify_snapshot( + snapshot("pub enum IdentityFailure { Missing }\n"), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Supported + ); + } + + #[test] + fn manual_copy_impl_refutes_but_aliasable_trait_impl_is_unsupported() { + let source = "pub struct IdentityFailure;\nimpl ::core::marker::Copy for IdentityFailure {}\nimpl Clone for IdentityFailure { fn clone(&self) -> Self { *self } }\n"; + let receipt = verify_snapshot( + snapshot(source), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!(receipt.claims[0].verdict, ClaimVerificationVerdict::Refuted); + assert_eq!( + receipt.claims[0].evidence[0].role, + ClaimEvidenceRole::CopyImplementation + ); + + let aliasable = verify_snapshot( + snapshot("pub struct IdentityFailure;\nimpl MaybeCopy for IdentityFailure {}\n"), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + aliasable.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let custom_derive = verify_snapshot( + snapshot("#[derive(External)] pub struct IdentityFailure;\n"), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + custom_derive.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn nested_copy_impl_makes_move_out_claims_unsupported() { + let receipt = verify_snapshot( + snapshot( + "pub struct IdentityFailure;\nimpl Clone for IdentityFailure { fn clone(&self) -> Self { *self } }\nfn install() { impl ::core::marker::Copy for IdentityFailure {} }\n", + ), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + assert!(receipt.claims[0].evidence.is_empty()); + } + + #[test] + fn block_macro_that_can_generate_copy_impl_makes_move_out_claims_unsupported() { + let receipt = verify_snapshot( + snapshot( + "pub struct IdentityFailure;\nimpl Clone for IdentityFailure { fn clone(&self) -> Self { *self } }\nmacro_rules! install_copy { () => { impl ::core::marker::Copy for IdentityFailure {} } }\nfn install() { install_copy!(); }\n", + ), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + assert!(receipt.claims[0].evidence.is_empty()); + } + + #[test] + fn imported_copy_impl_cannot_support_a_move_out_claim() { + let receipt = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\nmod other;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct Failure;\n".into(), + }, + MachineSourceFile { + path: "src/other.rs".into(), + source: "use crate::identity::Failure;\nimpl Copy for Failure {}\nimpl Clone for Failure { fn clone(&self) -> Self { *self } }\n".into(), + }, + ], + }, + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::Failure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let renamed_alias = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\nmod other;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct Failure;\n".into(), + }, + MachineSourceFile { + path: "src/other.rs".into(), + source: "use crate::identity::Failure as Alias;\nimpl ::core::marker::Copy for Alias {}\n".into(), + }, + ], + }, + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::Failure", + )], + ); + assert_eq!( + renamed_alias.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn bare_or_shadowed_copy_traits_never_refute() { + for source in [ + "pub trait Copy {}\npub struct IdentityFailure;\nimpl Copy for IdentityFailure {}\n", + "mod traits { pub trait Copy {} }\nuse traits::Copy;\npub struct IdentityFailure;\nimpl Copy for IdentityFailure {}\n", + "mod core { pub mod marker { pub trait Copy {} } }\npub struct IdentityFailure;\nimpl core::marker::Copy for IdentityFailure {}\n", + ] { + let receipt = verify_snapshot( + snapshot(source), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + } + + #[test] + fn absolute_copy_paths_fail_closed_when_extern_crate_aliases_shadow_roots() { + for root in ["core", "std"] { + let builtin_root = if root == "core" { "std" } else { "core" }; + let source = format!( + "extern crate self as {root};\npub mod marker {{ pub trait Copy {{}} }}\npub struct Failure;\nimpl ::{root}::marker::Copy for Failure {{}}\nfn require_builtin_copy() {{}}\npub fn check() {{ require_builtin_copy::(); }}\n" + ); + let receipt = verify_snapshot( + root_snapshot(&source), + &[root_claim( + MachineClaimKind::RustCopyMoveOut, + "crate::Failure", + )], + ); + + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported, + "::{root} must not be assumed to name the built-in Copy trait" + ); + } + } + + #[test] + fn ambiguous_symbols_are_unresolved() { + let receipt = verify_snapshot( + snapshot( + "#[cfg(a)] pub struct IdentityFailure;\n#[cfg(b)] pub struct IdentityFailure;\n", + ), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unresolved + ); + + let duplicate_module = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + MachineSourceFile { + path: "src/identity/mod.rs".into(), + source: String::new(), + }, + ], + }, + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + duplicate_module.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn macro_generated_symbols_are_unsupported() { + let receipt = verify_snapshot( + snapshot("make_identity_failure!();\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let imported = verify_snapshot( + snapshot("pub use crate::other::IdentityFailure;\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + imported.claims[0].verdict, + ClaimVerificationVerdict::Refuted + ); + + let glob = verify_snapshot( + snapshot("pub use crate::other::*;\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + glob.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let sibling_attribute = verify_snapshot( + snapshot("#[external] pub struct Source;\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + sibling_attribute.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let sibling_derive = verify_snapshot( + snapshot("#[derive(External)] pub struct Source;\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + sibling_derive.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let conditional_sibling_attribute = verify_snapshot( + snapshot("#[cfg_attr(feature = \"generated\", external)] pub struct Source;\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + conditional_sibling_attribute.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn macro_exports_taint_crate_root_symbol_absence() { + for (source, symbol) in [ + ( + "fn install() { #[macro_export] macro_rules! exported_from_block_uninvoked { () => {} } }\n", + "crate::exported_from_block_uninvoked", + ), + ( + "fn install() { #[macro_export] macro_rules! exported_from_block { () => {} } }\nexported_from_block!();\n", + "crate::exported_from_block", + ), + ( + "mod nested { #[macro_export] macro_rules! exported_from_nested_uninvoked { () => {} } }\n", + "crate::exported_from_nested_uninvoked", + ), + ( + "mod nested { #[macro_export] macro_rules! exported_from_nested { () => {} } }\nexported_from_nested!();\n", + "crate::exported_from_nested", + ), + ( + "macro_rules! install_export { () => { #[macro_export] macro_rules! exported_from_invocation { () => {} } } }\nfn install() { install_export!(); }\n", + "crate::exported_from_invocation", + ), + ] { + let receipt = verify_snapshot( + root_snapshot(source), + &[root_claim(MachineClaimKind::SymbolAbsent, symbol)], + ); + + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported, + "macro expansion or export can create {symbol} in the crate root" + ); + } + } + + #[test] + fn builtin_looking_derives_taint_namespace_absence_without_resolution() { + for source in [ + "use custom_derive::Copy;\n#[derive(Copy)] pub struct Source;\npub fn generated(_: GeneratedByShadowedDerive) {}\n", + "#[derive(Copy)] pub struct Source;\n", + "#[derive(Debug)] pub struct Source;\n", + ] { + let receipt = verify_snapshot( + snapshot(source), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::GeneratedByShadowedDerive", + )], + ); + + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + } + + #[test] + fn absence_is_supported_only_under_a_proven_module_namespace() { + let claims = [ + claim(MachineClaimKind::SymbolAbsent, "crate::identity::Missing"), + claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::State::Ready", + ), + claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::Record::value", + ), + claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::Unknown::Missing", + ), + ]; + let receipt = verify_snapshot( + snapshot("pub enum State { Ready }\npub struct Record { value: usize }\n"), + &claims, + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Supported + ); + for proof in &receipt.claims[1..] { + assert!(matches!( + proof.verdict, + ClaimVerificationVerdict::Unsupported | ClaimVerificationVerdict::Unresolved + )); + } + } + + #[test] + fn only_unambiguously_reachable_modules_contribute_proofs() { + let source_claim = claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + ); + let orphan = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: String::new(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + ], + }, + std::slice::from_ref(&source_claim), + ); + assert_eq!( + orphan.claims[0].verdict, + ClaimVerificationVerdict::Unresolved + ); + + let nested_claim = MachineClaim { + kind: MachineClaimKind::RustCopyMoveOut, + path: "src/outer/nested.rs".into(), + symbol: "crate::outer::nested::IdentityFailure".into(), + expected_signature: None, + }; + let reachable = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod outer;\n".into(), + }, + MachineSourceFile { + path: "src/outer.rs".into(), + source: "mod nested;\n".into(), + }, + MachineSourceFile { + path: "src/outer/nested.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + ], + }, + &[nested_claim], + ); + assert_eq!( + reachable.claims[0].verdict, + ClaimVerificationVerdict::Supported + ); + + for root_source in [ + "#[path = \"identity.rs\"] mod identity;\n", + "#[cfg(feature = \"identity\")] mod identity;\n", + ] { + let uncertain = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: root_source.into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + ], + }, + std::slice::from_ref(&source_claim), + ); + assert!(matches!( + uncertain.claims[0].verdict, + ClaimVerificationVerdict::Unsupported | ClaimVerificationVerdict::Unresolved + )); + } + + for crate_attribute in [ + "#![cfg(feature = \"identity\")]\n", + "#![cfg_attr(feature = \"minimal\", no_std)]\n", + ] { + let root_source = format!("{crate_attribute}mod identity;\n"); + let conditional_crate = MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: root_source, + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + ], + }; + let claims = [ + source_claim.clone(), + claim(MachineClaimKind::SymbolAbsent, "crate::identity::Missing"), + ]; + let uncertain = verify_snapshot(conditional_crate, &claims); + assert!(uncertain.claims.iter().all(|proof| matches!( + proof.verdict, + ClaimVerificationVerdict::Unsupported | ClaimVerificationVerdict::Unresolved + ))); + } + + let duplicate_roots = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\n".into(), + }, + MachineSourceFile { + path: "src/main.rs".into(), + source: "mod identity;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + ], + }, + &[source_claim], + ); + assert!(matches!( + duplicate_roots.claims[0].verdict, + ClaimVerificationVerdict::Unsupported | ClaimVerificationVerdict::Unresolved + )); + } + + #[test] + fn conditional_parent_modules_make_nested_symbols_unsupported() { + let mut nested_claim = claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::conditional::IdentityFailure", + ); + let receipt = verify_snapshot( + snapshot( + "#[cfg(feature = \"conditional\")] mod conditional { pub struct IdentityFailure; }\n", + ), + std::slice::from_ref(&nested_claim), + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let external_module_claim = claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + ); + let receipt = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "#[cfg(feature = \"identity\")] mod identity;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub struct IdentityFailure;\n".into(), + }, + ], + }, + &[external_module_claim], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + nested_claim.kind = MachineClaimKind::SignatureMismatch; + nested_claim.symbol = "crate::identity::conditional::IdentityFailure::check".into(); + nested_claim.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::Shared, + parameters: Vec::new(), + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }); + let receipt = verify_snapshot( + snapshot( + "#[cfg(feature = \"conditional\")] mod conditional { pub struct IdentityFailure; impl IdentityFailure { pub fn check(&self) -> bool { true } } }\n", + ), + &[nested_claim], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn malformed_source_is_unsupported_and_symlinks_are_rejected() { + let receipt = verify_snapshot( + snapshot("pub struct Broken {\n"), + &[claim( + MachineClaimKind::SymbolAbsent, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + assert!(regular_rust_source_entry("src/identity.rs", "100644")); + assert!(!regular_rust_source_entry("src/identity.rs", "120000")); + assert!(!regular_rust_source_entry("src/*.rs", "100644")); + } + + #[test] + fn signature_claims_use_the_bounded_type_grammar() { + let mut mismatch = claim( + MachineClaimKind::SignatureMismatch, + "crate::identity::IdentityFailure::reason", + ); + mismatch.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::Shared, + parameters: vec!["&str".into()], + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }); + let source = "pub struct IdentityFailure;\nimpl IdentityFailure { pub fn reason(&self, value: &str) -> bool { !value.is_empty() } }\n"; + let matching = verify_snapshot(snapshot(source), &[mismatch.clone()]); + assert_eq!( + matching.claims[0].verdict, + ClaimVerificationVerdict::Refuted + ); + mismatch.expected_signature.as_mut().unwrap().returns = "usize".into(); + let different = verify_snapshot(snapshot(source), &[mismatch]); + assert_eq!( + different.claims[0].verdict, + ClaimVerificationVerdict::Supported + ); + + let mut aliased = claim( + MachineClaimKind::SignatureMismatch, + "crate::identity::aliased", + ); + aliased.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::None, + parameters: Vec::new(), + returns: "crate::types::Expected".into(), + is_async: false, + is_unsafe: false, + }); + let alias = verify_snapshot( + snapshot( + "use crate::types::Expected as Actual;\npub fn aliased() -> Actual { todo!() }\n", + ), + &[aliased.clone()], + ); + assert_eq!( + alias.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let parse_incomplete = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\nmod broken;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub fn aliased() -> crate::types::Actual { todo!() }\n".into(), + }, + MachineSourceFile { + path: "src/broken.rs".into(), + source: "pub struct Broken {\n".into(), + }, + ], + }, + &[aliased], + ); + assert_eq!( + parse_incomplete.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn primitive_aliases_and_imports_cannot_prove_signature_inequality() { + for source in [ + "#[allow(non_camel_case_types)] type u8 = u16;\npub fn actual(_: u8) {}\nconst _: fn(u16) = actual;\n", + "mod aliases { pub type Narrow = u16; }\nuse aliases::Narrow as u8;\npub fn actual(_: u8) {}\n", + ] { + let mut signature = claim( + MachineClaimKind::SignatureMismatch, + "crate::identity::actual", + ); + signature.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::None, + parameters: vec!["u16".into()], + returns: "()".into(), + is_async: false, + is_unsafe: false, + }); + + let receipt = verify_snapshot(snapshot(source), &[signature]); + + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + } + + #[test] + fn signature_paths_equate_unshadowed_absolute_std_and_refuse_alias_mismatches() { + let mut absolute = claim( + MachineClaimKind::SignatureMismatch, + "crate::identity::vector", + ); + absolute.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::None, + parameters: Vec::new(), + returns: "::std::vec::Vec".into(), + is_async: false, + is_unsafe: false, + }); + let absolute = verify_snapshot( + snapshot("pub fn vector() -> std::vec::Vec { Vec::new() }\n"), + &[absolute], + ); + assert_eq!( + absolute.claims[0].verdict, + ClaimVerificationVerdict::Refuted + ); + + let mut alias_claim = claim( + MachineClaimKind::SignatureMismatch, + "crate::identity::aliased", + ); + alias_claim.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::None, + parameters: Vec::new(), + returns: "crate::types::Expected".into(), + is_async: false, + is_unsafe: false, + }); + for definition in [ + "pub struct Actual; pub type Expected = Actual;\n", + "pub struct Actual; pub use self::Actual as Expected;\n", + ] { + let receipt = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files: vec![ + MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity; mod types;\n".into(), + }, + MachineSourceFile { + path: "src/identity.rs".into(), + source: "pub fn aliased() -> crate::types::Actual { todo!() }\n".into(), + }, + MachineSourceFile { + path: "src/types.rs".into(), + source: definition.into(), + }, + ], + }, + &[alias_claim.clone()], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + } + + #[test] + fn signature_paths_preserve_shadowed_std_and_core_root_distinctions() { + for (source, symbol, returns) in [ + ( + "mod std { pub mod vec { pub struct Vec(pub T); } }\npub fn value() -> std::vec::Vec { loop {} }\n", + "crate::identity::value", + "::std::vec::Vec", + ), + ( + "mod core { pub mod option { pub enum Option { Some(T) } } }\npub fn value() -> core::option::Option { loop {} }\n", + "crate::identity::value", + "::core::option::Option", + ), + ( + "pub trait Shadow { fn value() -> std::Assoc; }\n", + "crate::identity::Shadow::value", + "::std::Assoc", + ), + ( + "pub trait Shadow { fn value() -> core::Assoc; }\n", + "crate::identity::Shadow::value", + "::core::Assoc", + ), + ] { + let mut signature = claim(MachineClaimKind::SignatureMismatch, symbol); + signature.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::None, + parameters: Vec::new(), + returns: returns.into(), + is_async: false, + is_unsafe: false, + }); + + let receipt = verify_snapshot(snapshot(source), &[signature]); + + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported, + "{returns} must remain distinct when its bare root is shadowed" + ); + } + } + + #[test] + fn signature_type_grammar_accepts_only_documented_path_roots() { + let mut signature = MachineSignature { + receiver: MachineReceiver::None, + parameters: Vec::new(), + returns: "::std::vec::Vec".into(), + is_async: false, + is_unsafe: false, + }; + assert!(normalize_signature(&signature).is_some()); + signature.returns = "::core::option::Option".into(); + assert!(normalize_signature(&signature).is_some()); + signature.returns = "crate::types::Expected".into(); + assert!(normalize_signature(&signature).is_some()); + + for rejected in [ + "::crate::types::Expected", + "String", + "Vec", + "alloc::vec::Vec", + ] { + signature.returns = rejected.into(); + assert!(normalize_signature(&signature).is_none(), "{rejected}"); + } + } + + #[test] + fn invalid_signature_syntax_is_unsupported() { + let mut mismatch = claim( + MachineClaimKind::SignatureMismatch, + "crate::identity::reason", + ); + mismatch.expected_signature = Some(MachineSignature { + receiver: MachineReceiver::None, + parameters: vec!["impl Display".into()], + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }); + let receipt = verify_snapshot( + snapshot("pub fn reason(value: impl std::fmt::Display) -> bool { true }\n"), + &[mismatch], + ); + assert_eq!( + receipt.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + + let alias = verify_snapshot( + snapshot("pub type IdentityFailure = u8;\n"), + &[claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + )], + ); + assert_eq!( + alias.claims[0].verdict, + ClaimVerificationVerdict::Unsupported + ); + } + + #[test] + fn missing_source_is_unavailable_and_stale_receipts_are_ignored() { + let source_claim = claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + ); + let unavailable = + unavailable_receipt(Some(&"a".repeat(40)), std::slice::from_ref(&source_claim)); + assert_eq!(unavailable.state, ClaimVerificationState::Unavailable); + assert_eq!( + receipt_verdict(Some(&unavailable), &source_claim, Some(&"a".repeat(40))), + None + ); + + let complete = verify_snapshot( + snapshot("#[derive(Copy, Clone)] pub struct IdentityFailure;\n"), + std::slice::from_ref(&source_claim), + ); + assert_eq!( + receipt_verdict(Some(&complete), &source_claim, Some(&"c".repeat(40))), + None + ); + } + + #[test] + fn source_and_claim_bounds_fail_closed() { + let claims = (0..=MAX_CLAIMS) + .map(|index| { + claim( + MachineClaimKind::SymbolAbsent, + &format!("crate::identity::Missing{index}"), + ) + }) + .collect::>(); + let receipt = verify_snapshot(snapshot("pub struct Present;\n"), &claims); + assert_eq!(receipt.state, ClaimVerificationState::Exhausted); + + let files = (0..=MAX_SOURCE_FILES) + .map(|index| MachineSourceFile { + path: format!("src/module_{index}.rs"), + source: String::new(), + }) + .collect(); + let receipt = verify_snapshot( + MachineSourceSnapshot { + head_sha: "a".repeat(40), + tree_sha256: "b".repeat(64), + files, + }, + &[], + ); + assert_eq!(receipt.state, ClaimVerificationState::Exhausted); + } + + #[test] + fn syntax_work_and_receipt_cardinality_fail_closed() { + let parsed = syn::parse_file("pub fn one() -> bool { true }\n").unwrap(); + let mut syntax_work = SyntaxWorkBudget::new(1); + syntax_work.visit_file(&parsed); + assert!(syntax_work.exhausted); + + let source_claim = claim( + MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + ); + let mut receipt = verify_snapshot( + snapshot("#[derive(Copy, Clone)] pub struct IdentityFailure;\n"), + std::slice::from_ref(&source_claim), + ); + receipt.claims.push(receipt.claims[0].clone()); + assert_eq!( + receipt_verdict(Some(&receipt), &source_claim, Some(&"a".repeat(40))), + None + ); + + let invalid_head = "a".repeat(MAX_RECEIPT_BYTES + 1); + let bounded = exhausted_receipt(Some(&invalid_head), &[source_claim]); + assert!(bounded.head_sha.is_none()); + assert!(serde_json::to_vec(&bounded).unwrap().len() <= MAX_RECEIPT_BYTES); + } +} diff --git a/src/output.rs b/src/output.rs index 075cf52..5b3b270 100644 --- a/src/output.rs +++ b/src/output.rs @@ -315,6 +315,8 @@ mod tests { scorer_kind: Some(Kind::HumanEscalation), scorer_reason: Some("The changed branch can skip authorization.".into()), repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Authorization branch can be skipped".into(), body: "The fallback path returns before the policy check.".into(), evidence: Some("return Ok(response);".into()), @@ -391,6 +393,7 @@ mod tests { projected_cost_micros: 900, }), repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 4567, base_sha: Some("base".into()), @@ -437,6 +440,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "\x1b[2Jhijacked title".into(), body: "line one\n\x1b[31mFAKE ALL CLEAR\x1b[0m\nline three".into(), evidence: None, @@ -466,6 +471,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -531,6 +537,7 @@ mod tests { }), review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, diff --git a/src/plan.rs b/src/plan.rs index 2c8f158..bb719e8 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -191,6 +191,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -213,6 +214,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "t".into(), body: "b".into(), evidence: None, diff --git a/src/prompt.rs b/src/prompt.rs index 2a980e2..b53e9dd 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -144,18 +144,20 @@ pub fn review_contract(cfg: &Config) -> String { `.postil/change-metadata`. Findings citing other lines are discarded as \ ungrounded.\n\ \n\ - `repositoryContext` is optional. Omit it for bugs the cited line establishes, including \ - removed fields, bypassed guards, boundary errors, or lifecycle defects; caller or consumer \ - impact alone doesn't require it. Include it only when the conclusion depends on \ - repository-wide evidence, using `claim: absence` for a construct missing from the \ - complete reviewed head or `claim: mismatch` for a repository target whose expected \ - value is not established by the cited changed line. When included, name the target in \ - `resources`, `paths`, or `identifiers` and the expected value in `values` or `versions`; \ - include all five arrays even when empty. Populated arrays are conjunctive and refute \ - only when matched in one file. Repository claims require the complete reviewed head. Public \ - text names the concrete construct \ - and correction, never review-input boundaries such as `in the diff`, retrieval mechanics, \ - delegated evidence collection, or guessed files.\n", + `repositoryContext` is optional. Omit it when the cited line proves the bug, including \ + removed fields, bypassed guards, boundary errors, and lifecycle defects; caller impact \ + alone does not require it. Use it only for a conclusion that depends on complete-head \ + absence or mismatch. Include all five arrays: targets belong in resources, paths, or \ + identifiers and expected values in values or versions. Populated arrays are conjunctive. \ + Public text names the defect and fix, never review boundaries, retrieval, delegation, or \ + guessed files.\n\ + \n\ + `machineClaim` XOR repositoryContext. kind=rust.copy_move_out|symbol.absent|signature.mismatch; \ + path=src/{lib.rs,main.rs,.rs}; symbol=crate::...; signature.mismatch \ + expectedSignature={receiver:none|shared|mutable|value,parameters,returns,async,unsafe}; \ + type=unshadowed primitive|crate/std/core path|&type|tuple|slice|!; no lifetimes; \ + leading :: only std/core; preserve. Omit resolution/expansion/compile-dependent claims. \ + Hide verification.\n", ); if !cfg.focus.is_empty() { p.push_str(&format!( @@ -224,6 +226,7 @@ pub fn system_prompt(cfg: &Config, current_utc_date: Date) -> String { \"findings\": [{\"path\": \"file path from the diff\", \"line\": ,\n \ \"endLine\": , \"severity\": \"info|warn|error\",\n \ \"kind\": \"risk|humanEscalation|guardrail|uncertainty|contentPolicy\", \"confidence\": <0..1>,\n \ + \"machineClaim\": ,\n \ \"title\": \"short imperative title\", \"body\": \"specific, evidence-based markdown\",\n \ \"evidence\": \"exact non-empty new-side text from the cited line\"}]}\n\ \n\ @@ -549,14 +552,31 @@ mod tests { let prompt = system_prompt(&Config::default(), trusted_date()); assert!(prompt.contains("`repositoryContext` is optional")); assert!(prompt.contains( - "Omit it for bugs the cited line establishes, including removed fields, bypassed guards, boundary errors, or lifecycle defects" + "Omit it when the cited line proves the bug, including removed fields, bypassed guards, boundary errors, and lifecycle defects" )); - assert!(prompt.contains("caller or consumer impact alone doesn't require it")); - assert!( - prompt.contains( - "Include it only when the conclusion depends on repository-wide evidence" - ) - ); + assert!(prompt.contains("caller impact alone does not require it")); + assert!(prompt.contains("depends on complete-head absence or mismatch")); + assert!(prompt.contains("Public text names the defect and fix")); + } + + #[test] + fn generator_machine_claim_contract_is_bounded_and_explicit() { + let prompt = system_prompt(&Config::default(), trusted_date()); + for kind in ["rust.copy_move_out", "symbol.absent", "signature.mismatch"] { + assert!(prompt.contains(kind)); + } + assert!(prompt.contains("path=src/{lib.rs,main.rs,.rs}")); + assert!(prompt.contains("symbol=crate::...")); + assert!(prompt.contains("`machineClaim` XOR repositoryContext")); + assert!(prompt.contains( + "expectedSignature={receiver:none|shared|mutable|value,parameters,returns,async,unsafe}" + )); + assert!(prompt.contains("crate/std/core path")); + assert!(prompt.contains("|&type|tuple|slice|!")); + assert!(prompt.contains("leading :: only std/core; preserve")); + assert!(prompt.contains("type=unshadowed primitive")); + assert!(prompt.contains("resolution/expansion/compile-dependent")); + assert!(prompt.contains("Hide verification")); } #[test] @@ -649,6 +669,7 @@ mod tests { \"findings\": [{\"path\": \"file path from the diff\", \"line\": ,\n \ \"endLine\": , \"severity\": \"info|warn|error\",\n \ \"kind\": \"risk|humanEscalation|guardrail|uncertainty|contentPolicy\", \"confidence\": <0..1>,\n \ + \"machineClaim\": ,\n \ \"title\": \"short imperative title\", \"body\": \"specific, evidence-based markdown\",\n \ \"evidence\": \"exact non-empty new-side text from the cited line\"}]}\n\ \n\ diff --git a/src/repository_search.rs b/src/repository_search.rs index 6a44d2d..0f0d012 100644 --- a/src/repository_search.rs +++ b/src/repository_search.rs @@ -2310,6 +2310,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: Some(claim), + machine_claim: None, + machine_claim_deferred: false, title: "Repository claim".into(), body: "The reviewed head has no matching value; add the required counterpart.".into(), evidence: Some("value: new".into()), diff --git a/src/resolve.rs b/src/resolve.rs index 340fc5a..fe14783 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -530,6 +530,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Resolve the uncertain behavior".to_string(), body: body.to_string(), evidence: None, diff --git a/src/review.rs b/src/review.rs index af42138..576a3ad 100644 --- a/src/review.rs +++ b/src/review.rs @@ -249,6 +249,17 @@ fn review_batch_validation_reason( ), }); } + if let Some(claim) = finding.machine_claim.as_ref() + && (finding.repository_claim.is_some() || !crate::machine_claim::claim_is_valid(claim)) + { + return Some(ReviewBatchValidationReason { + category: "machineClaim", + repair_detail: format!( + "finding at {}:{} has an invalid machineClaim declaration; do not combine it with repositoryContext, use one supported kind, an exact regular Rust path and crate-qualified symbol, and the bounded expectedSignature shape only for signature.mismatch", + finding.path, finding.line + ), + }); + } if diff::review_batch_contains_exact_evidence( annotated, @@ -1134,7 +1145,7 @@ fn generate_finding_ids(findings: &mut [Finding], head_sha: Option<&str>) { // Normalize the finding data let normalized_path = finding.path.to_lowercase(); let normalized_title = finding.title.trim().to_lowercase(); - let identity = if let Some(evidence) = finding.evidence.as_deref() { + let mut identity = if let Some(evidence) = finding.evidence.as_deref() { format!( "evidence\x00{}\x00{}\x00{}\x00{}", finding.kind.as_str(), @@ -1160,6 +1171,10 @@ fn generate_finding_ids(findings: &mut [Finding], head_sha: Option<&str>) { normalized_title ) }; + if let Some(claim) = finding.machine_claim.as_ref() { + identity.push_str("\0machine-claim\0"); + identity.push_str(&crate::machine_claim::claim_identity_sha256(claim)); + } // Generate SHA256 hash let mut hasher = Sha256::new(); @@ -2200,28 +2215,7 @@ async fn review_diff_at( model_used = batch_models.join(", "); } if total_requests > 0 { - let mut deduplicated = Vec::::new(); - let mut positions: HashMap<_, usize> = HashMap::new(); - for finding in raw_findings { - let key = ( - finding.path.clone(), - finding.kind.as_str().to_string(), - finding.title.trim().to_ascii_lowercase(), - finding.evidence.clone(), - ); - if let Some(position) = positions.get(&key).copied() { - let existing = &mut deduplicated[position]; - if (finding.severity, finding.confidence) - > (existing.severity, existing.confidence) - { - *existing = finding; - } - } else { - positions.insert(key, deduplicated.len()); - deduplicated.push(finding); - } - } - let raw_findings = deduplicated; + let raw_findings = deduplicate_generated_findings(raw_findings); let grounded_candidate_count = raw_findings.len(); let mut generator_filter_cfg = cfg.clone(); generator_filter_cfg.max_findings = generator_max_findings; @@ -2665,6 +2659,12 @@ async fn review_diff_at( .await } }; + let claim_verification = crate::machine_claim::verify( + &repository_source, + repository_revision.as_deref(), + findings.iter().chain(baseline.iter()), + ) + .await; let repository_suppressed = suppress_refuted_repository_claims( &mut findings, &repository_search, @@ -2673,6 +2673,16 @@ async fn review_diff_at( suppressed = suppressed.saturating_add(repository_suppressed.len() as u32); suppressed_findings.extend(repository_suppressed); + let machine_application = apply_machine_claim_policy( + &mut findings, + &mut baseline, + &adjudication_preserved_baseline, + claim_verification.as_ref(), + repository_revision.as_deref(), + ); + suppressed = suppressed.saturating_add(machine_application.suppressed.len() as u32); + suppressed_findings.extend(machine_application.suppressed); + // A question the reviewer never answered cannot block a merge. This runs // after uncertainty resolution so a finding that went and checked keeps the // severity it earned. @@ -2769,6 +2779,7 @@ async fn review_diff_at( let mut resolved = rec.resolved; resolved.extend(adjudication_resolved); + resolved.extend(machine_application.resolved); Ok(Envelope { version: 1, @@ -2794,6 +2805,7 @@ async fn review_diff_at( review_coverage, review_admission, repository_search, + claim_verification, usage_accounting_complete, duration_ms: review_started.elapsed().as_millis() as u64, base_sha: meta.map(|m| m.base_sha.clone()), @@ -2843,6 +2855,7 @@ fn qualification_plan_envelope( review_coverage: Some(review_coverage), review_admission: Some(review_admission), repository_search: crate::repository_search::unavailable(head_sha.as_deref()), + claim_verification: None, usage_accounting_complete: true, duration_ms, base_sha: meta.map(|value| value.base_sha.clone()), @@ -3147,6 +3160,90 @@ fn suppress_refuted_repository_claims( suppressed } +struct MachineClaimApplication { + suppressed: Vec, + resolved: Vec, +} + +fn apply_machine_claim_policy( + findings: &mut Vec, + baseline: &mut Vec, + adjudication_preserved_baseline: &[Finding], + receipt: Option<&crate::envelope::ClaimVerificationReceipt>, + expected_head_sha: Option<&str>, +) -> MachineClaimApplication { + use crate::envelope::ClaimVerificationVerdict; + + let mut suppressed = Vec::new(); + let mut resolved = Vec::new(); + let mut kept_findings = Vec::with_capacity(findings.len()); + for mut finding in findings.drain(..) { + let Some(claim) = finding.machine_claim.as_ref() else { + kept_findings.push(finding); + continue; + }; + let verdict = crate::machine_claim::receipt_verdict(receipt, claim, expected_head_sha); + let preserved_baseline = adjudication_preserved_baseline + .iter() + .any(|baseline| same_visible_finding(baseline, &finding)); + if preserved_baseline { + if verdict == Some(ClaimVerificationVerdict::Refuted) { + resolved.push(finding); + } else { + finding.machine_claim_deferred = true; + finding.severity = crate::envelope::Severity::Info; + kept_findings.push(finding); + } + continue; + } + match verdict { + Some(ClaimVerificationVerdict::Supported) => { + finding.machine_claim_deferred = false; + kept_findings.push(finding); + } + Some(ClaimVerificationVerdict::Refuted) => suppressed.push(SuppressedFinding { + finding, + reason: SuppressionReason::MachineClaimRefuted, + }), + Some(ClaimVerificationVerdict::Unresolved | ClaimVerificationVerdict::Unsupported) + | None => suppressed.push(SuppressedFinding { + finding, + reason: SuppressionReason::MachineClaimUnverified, + }), + } + } + *findings = kept_findings; + + let mut kept_baseline = Vec::with_capacity(baseline.len()); + for mut finding in baseline.drain(..) { + let Some(claim) = finding.machine_claim.as_ref() else { + kept_baseline.push(finding); + continue; + }; + if crate::machine_claim::receipt_verdict(receipt, claim, expected_head_sha) + == Some(ClaimVerificationVerdict::Refuted) + { + resolved.push(finding); + continue; + } + if adjudication_preserved_baseline + .iter() + .any(|preserved| same_visible_finding(preserved, &finding)) + { + continue; + } + finding.machine_claim_deferred = true; + finding.severity = crate::envelope::Severity::Info; + kept_baseline.push(finding); + } + *baseline = kept_baseline; + + MachineClaimApplication { + suppressed, + resolved, + } +} + fn same_visible_finding(a: &Finding, b: &Finding) -> bool { a.path == b.path && a.line == b.line @@ -3154,10 +3251,43 @@ fn same_visible_finding(a: &Finding, b: &Finding) -> bool { && a.severity == b.severity && a.kind == b.kind && a.confidence.to_bits() == b.confidence.to_bits() + && a.machine_claim + .as_ref() + .map(crate::machine_claim::claim_identity_sha256) + == b.machine_claim + .as_ref() + .map(crate::machine_claim::claim_identity_sha256) && a.title == b.title && visible_body(&a.body) == visible_body(&b.body) } +fn deduplicate_generated_findings(raw_findings: Vec) -> Vec { + let mut deduplicated = Vec::::new(); + let mut positions: HashMap<_, usize> = HashMap::new(); + for finding in raw_findings { + let key = ( + finding.path.clone(), + finding.kind.as_str().to_string(), + finding.title.trim().to_ascii_lowercase(), + finding.evidence.clone(), + finding + .machine_claim + .as_ref() + .map(crate::machine_claim::claim_identity_sha256), + ); + if let Some(position) = positions.get(&key).copied() { + let existing = &mut deduplicated[position]; + if (finding.severity, finding.confidence) > (existing.severity, existing.confidence) { + *existing = finding; + } + } else { + positions.insert(key, deduplicated.len()); + deduplicated.push(finding); + } + } + deduplicated +} + fn visible_body(body: &str) -> &str { body.strip_prefix(filter::CARRIED_MARKER) .map(str::trim_start) @@ -3304,6 +3434,7 @@ fn error_envelope( review_coverage: review_failure.and_then(|failure| failure.review_coverage.clone()), review_admission: review_failure.and_then(|failure| failure.review_admission), repository_search: crate::repository_search::unavailable(Some(head_sha)), + claim_verification: None, usage_accounting_complete: review_failure .is_none_or(|failure| failure.usage_accounting_complete), duration_ms, @@ -3351,12 +3482,12 @@ mod tests { let below_floor = format!("Benchmark pull request{}", "x".repeat(442)); let local_budgets = batch_budgets_for_title(&local_edge); - assert_eq!(local_budgets.synthesis, 4_227); - assert_eq!(local_budgets.source, 4_228); + assert_eq!(local_budgets.synthesis, 4_116); + assert_eq!(local_budgets.source, 4_117); assert!(review_batch_budgets_are_usable(local_budgets)); let ci_budgets = batch_budgets_for_title(&ci_edge); - assert_eq!(ci_budgets.synthesis, 4_211); - assert_eq!(ci_budgets.source, 4_212); + assert_eq!(ci_budgets.synthesis, 4_100); + assert_eq!(ci_budgets.source, 4_101); assert!(review_batch_budgets_are_usable(ci_budgets)); assert_eq!( ci_budgets.stabilized_for_rendering(), @@ -3368,7 +3499,7 @@ mod tests { let below_floor_budgets = batch_budgets_for_title(&below_floor); assert_eq!( below_floor_budgets.synthesis, - diff::MIN_REVIEW_BATCH_BYTES - 269 + diff::MIN_REVIEW_BATCH_BYTES - 380 ); assert!(!review_batch_budgets_are_usable(below_floor_budgets)); } @@ -3697,6 +3828,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: title.to_string(), body: body.to_string(), evidence: None, @@ -3959,6 +4092,92 @@ mod tests { ); } + #[test] + fn stable_ids_include_canonical_machine_claim_identity() { + let head_sha = "0123456789abcdef0123456789abcdef01234567"; + let mut first = finding_with_title( + "src/identity.rs", + 42, + "Signature mismatch", + "The signature differs.", + ); + first.machine_claim = Some(crate::envelope::MachineClaim { + kind: crate::envelope::MachineClaimKind::SignatureMismatch, + path: "src/identity.rs".into(), + symbol: "crate::identity::check".into(), + expected_signature: Some(crate::envelope::MachineSignature { + receiver: crate::envelope::MachineReceiver::None, + parameters: vec!["std::vec::Vec < u8 >".into()], + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }), + }); + let mut equivalent = first.clone(); + equivalent + .machine_claim + .as_mut() + .unwrap() + .expected_signature + .as_mut() + .unwrap() + .parameters = vec!["std::vec::Vec".into()]; + let mut distinct = equivalent.clone(); + distinct.machine_claim.as_mut().unwrap().symbol = "crate::identity::other".into(); + + generate_finding_ids(std::slice::from_mut(&mut first), Some(head_sha)); + generate_finding_ids(std::slice::from_mut(&mut equivalent), Some(head_sha)); + generate_finding_ids(std::slice::from_mut(&mut distinct), Some(head_sha)); + + assert_eq!(first.id, equivalent.id); + assert_ne!(first.id, distinct.id); + } + + #[test] + fn generated_dedup_keeps_sibling_findings_with_distinct_machine_claims() { + let mut first = finding_with_title( + "src/identity.rs", + 42, + "Signature mismatch", + "The signature differs.", + ); + first.evidence = Some("fn check()".into()); + first.machine_claim = Some(crate::envelope::MachineClaim { + kind: crate::envelope::MachineClaimKind::SignatureMismatch, + path: "src/identity.rs".into(), + symbol: "crate::identity::first".into(), + expected_signature: Some(crate::envelope::MachineSignature { + receiver: crate::envelope::MachineReceiver::None, + parameters: Vec::new(), + returns: "bool".into(), + is_async: false, + is_unsafe: false, + }), + }); + let mut distinct = first.clone(); + distinct.machine_claim.as_mut().unwrap().symbol = "crate::identity::second".into(); + let mut equivalent = first.clone(); + equivalent + .machine_claim + .as_mut() + .unwrap() + .expected_signature + .as_mut() + .unwrap() + .returns = " bool ".into(); + + let deduplicated = deduplicate_generated_findings(vec![first, distinct, equivalent]); + + assert_eq!(deduplicated.len(), 2); + let identities = deduplicated + .iter() + .map(|finding| { + crate::machine_claim::claim_identity_sha256(finding.machine_claim.as_ref().unwrap()) + }) + .collect::>(); + assert_eq!(identities.len(), 2); + } + #[test] fn visible_finding_comparison_ignores_carry_marker_and_order() { let previous = vec![finding("a.rs", 10, "first"), finding("b.rs", 20, "second")]; @@ -4099,6 +4318,200 @@ mod tests { assert_eq!(fresh.len(), 1); } + fn machine_claim( + kind: crate::envelope::MachineClaimKind, + symbol: &str, + ) -> crate::envelope::MachineClaim { + crate::envelope::MachineClaim { + kind, + path: "src/identity.rs".into(), + symbol: symbol.into(), + expected_signature: None, + } + } + + fn machine_receipt( + head_sha: &str, + source: &str, + claims: &[crate::envelope::MachineClaim], + ) -> crate::envelope::ClaimVerificationReceipt { + crate::machine_claim::verify_snapshot( + crate::machine_claim::MachineSourceSnapshot { + head_sha: head_sha.into(), + tree_sha256: "b".repeat(64), + files: vec![ + crate::machine_claim::MachineSourceFile { + path: "src/lib.rs".into(), + source: "mod identity;\n".into(), + }, + crate::machine_claim::MachineSourceFile { + path: "src/identity.rs".into(), + source: source.into(), + }, + ], + }, + claims, + ) + } + + #[test] + fn adjudication_rejection_is_not_resurrected_by_a_supported_machine_claim() { + let source_claim = machine_claim( + crate::envelope::MachineClaimKind::RustCopyMoveOut, + "crate::identity::IdentityFailure", + ); + let receipt = machine_receipt( + &"a".repeat(40), + "pub struct IdentityFailure;\n", + std::slice::from_ref(&source_claim), + ); + let mut rejected = finding("src/identity.rs", 1, "rejected machine claim"); + rejected.machine_claim = Some(source_claim); + let ordinary = finding("src/identity.rs", 3, "ordinary finding"); + let application = crate::adjudication::AdjudicationApplication { + kept: vec![ordinary.clone()], + kept_indices: vec![1], + unresolved_indices: Vec::new(), + resolved_indices: vec![0], + suppressed: vec![SuppressedFinding { + finding: rejected.clone(), + reason: SuppressionReason::BelowConfidence, + }], + }; + let mut findings = application.kept; + let machine_application = apply_machine_claim_policy( + &mut findings, + &mut Vec::new(), + &[], + Some(&receipt), + Some(&"a".repeat(40)), + ); + + assert_eq!(findings.len(), 1); + assert!(same_visible_finding(&findings[0], &ordinary)); + assert!( + !findings + .iter() + .any(|finding| same_visible_finding(finding, &rejected)) + ); + assert!(machine_application.suppressed.is_empty()); + assert!(machine_application.resolved.is_empty()); + } + + #[test] + fn fresh_machine_claim_policy_suppresses_refuted_and_unverified_claims() { + let head_sha = "a".repeat(40); + let refuted_claim = machine_claim( + crate::envelope::MachineClaimKind::RustCopyMoveOut, + "crate::identity::CopyFailure", + ); + let supported_claim = machine_claim( + crate::envelope::MachineClaimKind::RustCopyMoveOut, + "crate::identity::MoveFailure", + ); + let receipt = machine_receipt( + &head_sha, + "pub struct CopyFailure;\nimpl ::core::marker::Copy for CopyFailure {}\nimpl Clone for CopyFailure { fn clone(&self) -> Self { *self } }\npub struct MoveFailure;\n", + &[refuted_claim.clone(), supported_claim.clone()], + ); + let mut refuted = finding("src/identity.rs", 1, "copy value is moved"); + refuted.machine_claim = Some(refuted_claim); + let mut supported = finding("src/identity.rs", 2, "non-copy value is moved"); + supported.machine_claim = Some(supported_claim); + let mut findings = vec![refuted, supported.clone()]; + let mut baseline = Vec::new(); + + let application = apply_machine_claim_policy( + &mut findings, + &mut baseline, + &[], + Some(&receipt), + Some(&head_sha), + ); + assert_eq!(findings.len(), 1); + assert!(same_visible_finding(&findings[0], &supported)); + assert_eq!(application.suppressed.len(), 1); + assert_eq!( + application.suppressed[0].reason, + SuppressionReason::MachineClaimRefuted + ); + + let mut stale_findings = vec![supported]; + let stale = "c".repeat(40); + let stale_application = apply_machine_claim_policy( + &mut stale_findings, + &mut Vec::new(), + &[], + Some(&receipt), + Some(&stale), + ); + assert!(stale_findings.is_empty()); + assert_eq!( + stale_application.suppressed[0].reason, + SuppressionReason::MachineClaimUnverified + ); + } + + #[test] + fn carried_machine_claims_resolve_only_from_fresh_refutation() { + let head_sha = "a".repeat(40); + let refuted_claim = machine_claim( + crate::envelope::MachineClaimKind::RustCopyMoveOut, + "crate::identity::CopyFailure", + ); + let supported_claim = machine_claim( + crate::envelope::MachineClaimKind::RustCopyMoveOut, + "crate::identity::MoveFailure", + ); + let receipt = machine_receipt( + &head_sha, + "pub struct CopyFailure;\nimpl ::core::marker::Copy for CopyFailure {}\nimpl Clone for CopyFailure { fn clone(&self) -> Self { *self } }\npub struct MoveFailure;\n", + &[refuted_claim.clone(), supported_claim.clone()], + ); + let mut refuted = finding("src/identity.rs", 1, "copy value is moved"); + refuted.machine_claim = Some(refuted_claim); + let mut deferred = finding("src/identity.rs", 2, "non-copy value is moved"); + deferred.machine_claim = Some(supported_claim); + let original_deferred = deferred.clone(); + let mut baseline = vec![refuted.clone(), deferred]; + let mut findings = Vec::new(); + + let application = apply_machine_claim_policy( + &mut findings, + &mut baseline, + &[], + Some(&receipt), + Some(&head_sha), + ); + assert_eq!(application.resolved.len(), 1); + assert!(same_visible_finding(&application.resolved[0], &refuted)); + assert_eq!(baseline.len(), 1); + assert!(baseline[0].machine_claim_deferred); + assert_eq!(baseline[0].severity, Severity::Info); + assert!(!crate::envelope::finding_blocks_gate( + &baseline[0], + "info", + &["risk".into()], + false + )); + assert_eq!(baseline[0].title, original_deferred.title); + + let mut preserved_findings = vec![refuted.clone()]; + let preserved_application = apply_machine_claim_policy( + &mut preserved_findings, + &mut Vec::new(), + std::slice::from_ref(&refuted), + Some(&receipt), + Some(&head_sha), + ); + assert!(preserved_findings.is_empty()); + assert_eq!(preserved_application.resolved.len(), 1); + assert!(same_visible_finding( + &preserved_application.resolved[0], + &refuted + )); + } + fn score(index: usize, confidence: f64, kind: Kind) -> FindingScore { FindingScore { index, diff --git a/src/sarif.rs b/src/sarif.rs index 9a9bb25..cd63f2b 100644 --- a/src/sarif.rs +++ b/src/sarif.rs @@ -154,6 +154,7 @@ mod tests { review_coverage: None, review_admission: None, repository_search: Default::default(), + claim_verification: None, usage_accounting_complete: true, duration_ms: 0, base_sha: None, @@ -177,6 +178,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Bug".into(), body: "details".into(), evidence: None, @@ -225,6 +228,8 @@ mod tests { scorer_kind: None, scorer_reason: None, repository_claim: None, + machine_claim: None, + machine_claim_deferred: false, title: "Check the sibling update".into(), body: "No sibling update appears in this diff; inspect the deployment manifest.".into(), evidence: None,