1use std::path::Path;
8
9use super::context_proof_v2::{
10 Claim, ClaimKind, ClaimStatus, ContextProofV2, VerifierKind, deterministic_claim, policy_claim,
11};
12
13pub struct ClaimExtractor {
14 proof: ContextProofV2,
15}
16
17impl ClaimExtractor {
18 pub fn new(run_id: &str, session_id: Option<&str>) -> Self {
19 Self {
20 proof: ContextProofV2::new(
21 run_id.to_string(),
22 session_id.map(std::string::ToString::to_string),
23 ),
24 }
25 }
26
27 pub fn verify_pathjail(&mut self, path: &str, jail_root: &Path) {
28 let resolved = crate::core::pathjail::jail_path(&std::path::PathBuf::from(path), jail_root);
29 let passed = resolved.is_ok();
30 self.proof.add_claim(policy_claim(
31 &format!("pathjail:{path}"),
32 &format!("Path '{path}' is within jail root"),
33 ClaimKind::PathjailCompliance,
34 passed,
35 ));
36 }
37
38 pub fn verify_no_secrets_in_output(&mut self, output: &str) {
39 let secret_matches = crate::core::secret_detection::detect_secrets(output);
40
41 let passed = secret_matches.is_empty();
42 let text = if passed {
43 "No secret patterns found in output".to_string()
44 } else {
45 let names: Vec<&str> = secret_matches.iter().map(|m| m.pattern_name).collect();
46 let mut unique_names: Vec<&str> = names.clone();
47 unique_names.sort_unstable();
48 unique_names.dedup();
49 format!(
50 "Secret patterns detected ({}): {}",
51 secret_matches.len(),
52 unique_names.join(", ")
53 )
54 };
55
56 self.proof.add_claim(policy_claim(
57 "no_secrets",
58 &text,
59 ClaimKind::SecretPolicy,
60 passed,
61 ));
62 }
63
64 pub fn verify_signatures_preserved(&mut self, original: &[String], compressed: &[String]) {
65 let mut missing = Vec::new();
66 for sig in original {
67 if !compressed.iter().any(|c| c.contains(sig)) {
68 missing.push(sig.clone());
69 }
70 }
71
72 let passed = missing.is_empty();
73 let text = if passed {
74 format!("All {} signatures preserved", original.len())
75 } else {
76 format!(
77 "{} of {} signatures missing: {:?}",
78 missing.len(),
79 original.len(),
80 &missing[..missing.len().min(3)]
81 )
82 };
83
84 self.proof.add_claim(Claim {
85 id: "signatures_preserved".to_string(),
86 text,
87 kind: ClaimKind::CompressionInvariant,
88 verifier: VerifierKind::Ast,
89 status: if passed {
90 ClaimStatus::Passed
91 } else {
92 ClaimStatus::Failed
93 },
94 evidence_ref: None,
95 });
96 }
97
98 pub fn verify_imports_preserved(&mut self, original_imports: &[String], compressed: &str) {
99 let mut missing = Vec::new();
100 for imp in original_imports {
101 if !compressed.contains(imp) {
102 missing.push(imp.clone());
103 }
104 }
105
106 let passed = missing.is_empty();
107 let text = if passed {
108 format!("All {} imports preserved", original_imports.len())
109 } else {
110 format!(
111 "{} of {} imports missing",
112 missing.len(),
113 original_imports.len()
114 )
115 };
116
117 self.proof
118 .add_claim(deterministic_claim("imports_preserved", &text, passed));
119 }
120
121 pub fn add_custom_claim(&mut self, claim: Claim) {
122 self.proof.add_claim(claim);
123 }
124
125 pub fn finalize(self) -> ContextProofV2 {
126 self.proof
127 }
128}
129
130#[cfg(test)]
131pub mod tests {
132 use super::*;
133
134 #[test]
135 fn secret_detection_catches_aws_keys() {
136 let mut ext = ClaimExtractor::new("test_1", None);
137 ext.verify_no_secrets_in_output(concat!("const key = 'AK", "IAIOSFODNN7EXAMPLE'"));
138 let proof = ext.finalize();
139 assert_eq!(proof.summary.failed, 1);
140 }
141
142 #[test]
143 fn clean_output_passes_secret_check() {
144 let mut ext = ClaimExtractor::new("test_2", None);
145 ext.verify_no_secrets_in_output("fn main() { println!(\"hello\"); }");
146 let proof = ext.finalize();
147 assert_eq!(proof.summary.passed, 1);
148 assert_eq!(proof.summary.failed, 0);
149 }
150
151 #[test]
152 fn signatures_preserved_check() {
153 let mut ext = ClaimExtractor::new("test_3", None);
154 let original = vec!["fn main".to_string(), "pub fn process".to_string()];
155 let compressed = vec![
156 "fn main() { ... }".to_string(),
157 "pub fn process(data: &[u8]) -> Result<(), Error>".to_string(),
158 ];
159 ext.verify_signatures_preserved(&original, &compressed);
160 let proof = ext.finalize();
161 assert_eq!(proof.summary.passed, 1);
162 }
163
164 #[test]
165 fn missing_signatures_detected() {
166 let mut ext = ClaimExtractor::new("test_4", None);
167 let original = vec!["fn main()".to_string(), "pub fn gone()".to_string()];
168 let compressed = vec!["fn main() { ... }".to_string()];
169 ext.verify_signatures_preserved(&original, &compressed);
170 let proof = ext.finalize();
171 assert_eq!(proof.summary.failed, 1);
172 }
173
174 #[test]
175 fn pathjail_claim_reports_what_the_build_enforces() {
176 let _iso = crate::core::data_dir::isolated_data_dir();
179 let root = tempfile::tempdir().unwrap();
180 let outside = tempfile::tempdir().unwrap();
181 let escaped = outside.path().join("secret.txt");
182 std::fs::write(&escaped, "outside the jail").unwrap();
183 let mut ext = ClaimExtractor::new("test_5", None);
184 ext.verify_pathjail(&escaped.to_string_lossy(), root.path());
185 let proof = ext.finalize();
186 let (failed, passed, level) = if cfg!(feature = "no-jail") {
190 (
191 0,
192 1,
193 super::super::context_proof_v2::QualityLevel::PolicyChecked,
194 )
195 } else {
196 (
197 1,
198 0,
199 super::super::context_proof_v2::QualityLevel::Provenance,
200 )
201 };
202 assert_eq!(proof.summary.failed, failed);
203 assert_eq!(proof.summary.passed, passed);
204 assert_eq!(proof.quality_level, level);
205 }
206
207 #[test]
208 fn combined_extraction_computes_quality() {
209 let _iso = crate::core::data_dir::isolated_data_dir();
210 let root = tempfile::tempdir().unwrap();
211 let inside = root.path().join("src.rs");
212 std::fs::write(&inside, "fn main() {}").unwrap();
213 let mut ext = ClaimExtractor::new("test_6", None);
214 ext.verify_no_secrets_in_output("clean code");
215 ext.verify_pathjail(&inside.to_string_lossy(), root.path());
216 let proof = ext.finalize();
217 assert_eq!(proof.summary.passed, 2);
218 assert_eq!(
219 proof.quality_level,
220 super::super::context_proof_v2::QualityLevel::PolicyChecked
221 );
222 }
223
224 #[test]
225 fn detects_github_pat() {
226 let mut ext = ClaimExtractor::new("test_gh", None);
227 ext.verify_no_secrets_in_output(concat!("token = gh", "p_1234567890abcdef"));
228 let proof = ext.finalize();
229 assert_eq!(proof.summary.failed, 1);
230 }
231
232 #[test]
233 fn detects_gitlab_pat() {
234 let mut ext = ClaimExtractor::new("test_gl", None);
235 ext.verify_no_secrets_in_output("glpat-xxxxxxxxxxxxxxxxxxxx");
236 let proof = ext.finalize();
237 assert_eq!(proof.summary.failed, 1);
238 }
239
240 #[test]
241 fn detects_pem_private_key() {
242 let mut ext = ClaimExtractor::new("test_pem", None);
243 ext.verify_no_secrets_in_output("-----BEGIN RSA PRIVATE KEY-----");
244 let proof = ext.finalize();
245 assert_eq!(proof.summary.failed, 1);
246 }
247
248 #[test]
249 fn empty_signatures_passes() {
250 let mut ext = ClaimExtractor::new("test_empty", None);
251 ext.verify_signatures_preserved(&[], &[]);
252 let proof = ext.finalize();
253 assert_eq!(proof.summary.passed, 1);
254 assert_eq!(proof.summary.failed, 0);
255 }
256
257 #[test]
258 fn empty_imports_passes() {
259 let mut ext = ClaimExtractor::new("test_imp", None);
260 ext.verify_imports_preserved(&[], "some content");
261 let proof = ext.finalize();
262 assert_eq!(proof.summary.passed, 1);
263 }
264
265 #[test]
266 fn finalize_returns_correct_run_id() {
267 let ext = ClaimExtractor::new("my_run_42", Some("sess_7"));
268 let proof = ext.finalize();
269 assert_eq!(proof.run_id, "my_run_42");
270 assert_eq!(proof.session_id, Some("sess_7".to_string()));
271 }
272
273 #[test]
274 fn multiple_secret_patterns_all_detected() {
275 let mut ext = ClaimExtractor::new("test_multi", None);
276 ext.verify_no_secrets_in_output(concat!(
277 "AK",
278 "IAIOSFODNN7EXAMPLE1 and ",
279 "sk",
280 "-abcdefghijklmnopqrstuvwx and password=longvalue1234567890extra",
281 ));
282 let proof = ext.finalize();
283 assert_eq!(proof.summary.failed, 1);
284 assert!(proof.claims[0].text.contains("aws_key"));
285 }
286}