1use std::collections::BTreeMap;
18use std::fs::{self, OpenOptions};
19use std::io::{BufRead, BufReader, Write};
20use std::path::{Path, PathBuf};
21
22use schemars::generate::SchemaSettings;
23use schemars::{JsonSchema, Schema};
24use serde::{Deserialize, Serialize};
25
26use crate::incident::IncidentReasonCode;
27use crate::schema_versions::{SchemaComponent, current_version};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31pub struct SourceFingerprint {
32 pub path: String,
34 pub blake3: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum StaleSourcePolicy {
42 RejectIfChanged,
44 AllowIfChanged,
46 AlwaysReject,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
52pub struct ReplayConstraints {
53 #[serde(default)]
55 pub require_same_revision: bool,
56 #[serde(default)]
58 pub require_unchanged_sources: bool,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub max_age_secs: Option<u64>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66pub struct ProofIntent {
67 pub schema_version: String,
69 pub intent_id: String,
71 pub command_digest: String,
73 pub cwd: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub repo_revision: Option<String>,
78 #[serde(default)]
80 pub source_fingerprints: Vec<SourceFingerprint>,
81 #[serde(default)]
83 pub env_allowlist: Vec<String>,
84 pub target_dir_policy: String,
86 #[serde(default)]
88 pub package_scope: Vec<String>,
89 #[serde(default)]
91 pub test_scope: Vec<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub requested_worker: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub requested_profile: Option<String>,
98 #[schemars(with = "String")]
101 pub denial_reason: IncidentReasonCode,
102 pub replay_constraints: ReplayConstraints,
104 pub stale_source_policy: StaleSourcePolicy,
106 pub recorded_at_unix_ms: u64,
108 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110 pub details: BTreeMap<String, String>,
111}
112
113impl ProofIntent {
114 #[must_use]
117 #[allow(clippy::too_many_arguments)]
118 pub fn new(
119 command_digest: impl Into<String>,
120 cwd: impl Into<String>,
121 repo_revision: Option<String>,
122 target_dir_policy: impl Into<String>,
123 denial_reason: IncidentReasonCode,
124 stale_source_policy: StaleSourcePolicy,
125 replay_constraints: ReplayConstraints,
126 recorded_at_unix_ms: u64,
127 ) -> Self {
128 let command_digest = command_digest.into();
129 let cwd = cwd.into();
130 let intent_id = derive_intent_id(&command_digest, &cwd, repo_revision.as_deref());
131 Self {
132 schema_version: proof_intent_schema_version().to_string(),
133 intent_id,
134 command_digest,
135 cwd,
136 repo_revision,
137 source_fingerprints: Vec::new(),
138 env_allowlist: Vec::new(),
139 target_dir_policy: target_dir_policy.into(),
140 package_scope: Vec::new(),
141 test_scope: Vec::new(),
142 requested_worker: None,
143 requested_profile: None,
144 denial_reason,
145 replay_constraints,
146 stale_source_policy,
147 recorded_at_unix_ms,
148 details: BTreeMap::new(),
149 }
150 }
151
152 #[must_use]
153 pub fn with_source_fingerprints(mut self, fps: Vec<SourceFingerprint>) -> Self {
154 self.source_fingerprints = fps;
155 self
156 }
157
158 #[must_use]
159 pub fn with_env_allowlist(mut self, names: Vec<String>) -> Self {
160 self.env_allowlist = names;
161 self
162 }
163}
164
165#[must_use]
167pub fn derive_intent_id(command_digest: &str, cwd: &str, repo_revision: Option<&str>) -> String {
168 let mut hasher = blake3::Hasher::new();
169 hasher.update(command_digest.as_bytes());
170 hasher.update(b"\0");
171 hasher.update(cwd.as_bytes());
172 hasher.update(b"\0");
173 hasher.update(repo_revision.unwrap_or("").as_bytes());
174 let hex = hasher.finalize().to_hex();
175 format!("pi-{}", &hex[..16])
176}
177
178#[must_use]
180pub fn proof_intent_schema_version() -> &'static str {
181 current_version(SchemaComponent::ProofIntent)
182}
183
184#[must_use]
186pub fn proof_intent_schema() -> Schema {
187 SchemaSettings::draft07()
188 .into_generator()
189 .into_root_schema_for::<ProofIntent>()
190}
191
192#[derive(Debug, Clone, Default)]
194pub struct ReplayContext {
195 pub current_revision: Option<String>,
196 pub current_fingerprints: Vec<SourceFingerprint>,
197 pub age_secs: u64,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ReplayDecision {
203 Replayable,
204 Rejected {
205 reason: IncidentReasonCode,
206 detail: String,
207 },
208}
209
210#[must_use]
213pub fn validate_replay(intent: &ProofIntent, ctx: &ReplayContext) -> ReplayDecision {
214 if intent.stale_source_policy == StaleSourcePolicy::AlwaysReject {
215 return ReplayDecision::Rejected {
216 reason: IncidentReasonCode::ProofRefusal,
217 detail: "intent is informational only (always_reject)".to_string(),
218 };
219 }
220 if let Some(max) = intent.replay_constraints.max_age_secs
221 && ctx.age_secs > max
222 {
223 return ReplayDecision::Rejected {
224 reason: IncidentReasonCode::ProofRefusal,
225 detail: format!("intent age {}s exceeds max {}s", ctx.age_secs, max),
226 };
227 }
228 if intent.replay_constraints.require_same_revision
229 && intent.repo_revision != ctx.current_revision
230 {
231 return ReplayDecision::Rejected {
232 reason: IncidentReasonCode::ProofRefusal,
233 detail: "repo revision changed since intent was recorded".to_string(),
234 };
235 }
236 let must_match = intent.replay_constraints.require_unchanged_sources
237 || intent.stale_source_policy == StaleSourcePolicy::RejectIfChanged;
238 if must_match && !fingerprints_match(&intent.source_fingerprints, &ctx.current_fingerprints) {
239 return ReplayDecision::Rejected {
240 reason: IncidentReasonCode::ProofRefusal,
241 detail: "source fingerprint mismatch (source changed)".to_string(),
242 };
243 }
244 ReplayDecision::Replayable
245}
246
247fn fingerprints_match(recorded: &[SourceFingerprint], current: &[SourceFingerprint]) -> bool {
249 if recorded.len() != current.len() {
250 return false;
251 }
252 let map: BTreeMap<&str, &str> = current
253 .iter()
254 .map(|f| (f.path.as_str(), f.blake3.as_str()))
255 .collect();
256 recorded
257 .iter()
258 .all(|f| map.get(f.path.as_str()) == Some(&f.blake3.as_str()))
259}
260
261#[derive(Debug, Clone)]
263pub struct ProofIntentStore {
264 path: PathBuf,
265}
266
267impl ProofIntentStore {
268 #[must_use]
269 pub fn with_path(path: impl Into<PathBuf>) -> Self {
270 Self { path: path.into() }
271 }
272
273 #[must_use]
274 pub fn path(&self) -> &Path {
275 &self.path
276 }
277
278 pub fn put(&self, intent: &ProofIntent) -> std::io::Result<()> {
281 if let Some(parent) = self.path.parent()
282 && !parent.as_os_str().is_empty()
283 {
284 fs::create_dir_all(parent)?;
285 }
286 let mut line = serde_json::to_string(intent)
287 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
288 line.push('\n');
289 let mut file = OpenOptions::new()
290 .create(true)
291 .append(true)
292 .open(&self.path)?;
293 file.write_all(line.as_bytes())?;
294 file.flush()
295 }
296
297 #[must_use]
300 pub fn all(&self) -> Vec<ProofIntent> {
301 let file = match fs::File::open(&self.path) {
302 Ok(f) => f,
303 Err(_) => return Vec::new(),
304 };
305 let mut order: Vec<String> = Vec::new();
308 let mut latest: BTreeMap<String, ProofIntent> = BTreeMap::new();
309 for line in BufReader::new(file).lines() {
310 let Ok(line) = line else { break };
311 let trimmed = line.trim();
312 if trimmed.is_empty() {
313 continue;
314 }
315 if let Ok(intent) = serde_json::from_str::<ProofIntent>(trimmed) {
316 if !latest.contains_key(&intent.intent_id) {
317 order.push(intent.intent_id.clone());
318 }
319 latest.insert(intent.intent_id.clone(), intent);
320 }
321 }
322 order
323 .into_iter()
324 .filter_map(|id| latest.remove(&id))
325 .collect()
326 }
327
328 #[must_use]
330 pub fn get(&self, intent_id: &str) -> Option<ProofIntent> {
331 self.all().into_iter().find(|i| i.intent_id == intent_id)
332 }
333
334 #[must_use]
336 pub fn by_reason(&self, reason: IncidentReasonCode) -> Vec<ProofIntent> {
337 self.all()
338 .into_iter()
339 .filter(|i| i.denial_reason == reason)
340 .collect()
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn intent() -> ProofIntent {
349 ProofIntent::new(
350 "blake3:abc123",
351 "/data/projects/foo",
352 Some("rev-1".to_string()),
353 "pooled",
354 IncidentReasonCode::ProofRefusal,
355 StaleSourcePolicy::RejectIfChanged,
356 ReplayConstraints {
357 require_same_revision: true,
358 require_unchanged_sources: true,
359 max_age_secs: Some(3600),
360 },
361 1_700_000_000_000,
362 )
363 .with_source_fingerprints(vec![SourceFingerprint {
364 path: "src/lib.rs".to_string(),
365 blake3: "deadbeef".to_string(),
366 }])
367 .with_env_allowlist(vec![
368 "CARGO_TARGET_DIR".to_string(),
369 "RUSTFLAGS".to_string(),
370 ])
371 }
372
373 #[test]
374 fn test_no_raw_or_secret_fields() {
375 let v = serde_json::to_value(intent()).unwrap();
377 let obj = v.as_object().unwrap();
378 for forbidden in [
379 "command",
380 "raw_command",
381 "env",
382 "env_values",
383 "secret",
384 "password",
385 "token",
386 ] {
387 assert!(
388 !obj.contains_key(forbidden),
389 "must not expose `{forbidden}`"
390 );
391 }
392 assert!(obj.contains_key("command_digest"));
394 assert_eq!(v["env_allowlist"][0], "CARGO_TARGET_DIR");
395 }
396
397 #[test]
398 fn intent_id_is_stable_and_derived() {
399 let a = derive_intent_id("d", "/cwd", Some("r"));
400 let b = derive_intent_id("d", "/cwd", Some("r"));
401 assert_eq!(a, b);
402 assert!(a.starts_with("pi-"));
403 assert_ne!(a, derive_intent_id("d", "/cwd", Some("r2")));
404 assert_eq!(
405 intent().intent_id,
406 derive_intent_id("blake3:abc123", "/data/projects/foo", Some("rev-1"))
407 );
408 }
409
410 #[test]
411 fn schema_version_stamped_and_versioned() {
412 assert_eq!(intent().schema_version, "1.0.0");
413 assert_eq!(proof_intent_schema_version(), "1.0.0");
414 let text = serde_json::to_string(&proof_intent_schema()).unwrap();
415 assert!(text.contains("ProofIntent"));
416 for f in [
417 "command_digest",
418 "intent_id",
419 "denial_reason",
420 "replay_constraints",
421 "stale_source_policy",
422 ] {
423 assert!(text.contains(f), "schema omits {f}");
424 }
425 }
426
427 #[test]
428 fn serde_roundtrips() {
429 let i = intent();
430 let back: ProofIntent = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
431 assert_eq!(i, back);
432 }
433
434 #[test]
435 fn replay_rejected_on_fingerprint_mismatch() {
436 let i = intent();
437 let ctx = ReplayContext {
439 current_revision: Some("rev-1".to_string()),
440 current_fingerprints: vec![SourceFingerprint {
441 path: "src/lib.rs".to_string(),
442 blake3: "CHANGED".to_string(),
443 }],
444 age_secs: 10,
445 };
446 match validate_replay(&i, &ctx) {
447 ReplayDecision::Rejected { reason, detail } => {
448 assert_eq!(reason, IncidentReasonCode::ProofRefusal);
449 assert!(detail.contains("fingerprint"));
450 }
451 other => panic!("expected rejection, got {other:?}"),
452 }
453 }
454
455 #[test]
456 fn replay_ok_when_unchanged() {
457 let i = intent();
458 let ctx = ReplayContext {
459 current_revision: Some("rev-1".to_string()),
460 current_fingerprints: vec![SourceFingerprint {
461 path: "src/lib.rs".to_string(),
462 blake3: "deadbeef".to_string(),
463 }],
464 age_secs: 10,
465 };
466 assert_eq!(validate_replay(&i, &ctx), ReplayDecision::Replayable);
467 }
468
469 #[test]
470 fn replay_rejected_on_revision_change_and_age() {
471 let i = intent();
472 let rev = ReplayContext {
473 current_revision: Some("rev-2".to_string()),
474 current_fingerprints: i.source_fingerprints.clone(),
475 age_secs: 10,
476 };
477 assert!(matches!(
478 validate_replay(&i, &rev),
479 ReplayDecision::Rejected { .. }
480 ));
481
482 let old = ReplayContext {
483 current_revision: Some("rev-1".to_string()),
484 current_fingerprints: i.source_fingerprints.clone(),
485 age_secs: 7200, };
487 match validate_replay(&i, &old) {
488 ReplayDecision::Rejected { detail, .. } => assert!(detail.contains("age")),
489 other => panic!("expected age rejection, got {other:?}"),
490 }
491 }
492
493 #[test]
494 fn always_reject_policy_never_replays() {
495 let mut i = intent();
496 i.stale_source_policy = StaleSourcePolicy::AlwaysReject;
497 let ctx = ReplayContext {
498 current_revision: i.repo_revision.clone(),
499 current_fingerprints: i.source_fingerprints.clone(),
500 age_secs: 0,
501 };
502 assert!(matches!(
503 validate_replay(&i, &ctx),
504 ReplayDecision::Rejected { .. }
505 ));
506 }
507
508 #[test]
509 fn store_dedups_duplicate_intents() {
510 let dir = tempfile::tempdir().unwrap();
511 let store = ProofIntentStore::with_path(dir.path().join("proofs.jsonl"));
512 let i = intent();
513 store.put(&i).unwrap();
514 store.put(&i).unwrap(); let mut updated = i.clone();
517 updated.target_dir_policy = "isolated".to_string();
518 store.put(&updated).unwrap();
519
520 let all = store.all();
521 assert_eq!(all.len(), 1, "duplicates dedup by intent_id");
522 assert_eq!(all[0].target_dir_policy, "isolated", "latest write wins");
523 }
524
525 #[test]
526 fn store_query_and_corruption_tolerance() {
527 let dir = tempfile::tempdir().unwrap();
528 let path = dir.path().join("proofs.jsonl");
529 let store = ProofIntentStore::with_path(&path);
530 store.put(&intent()).unwrap();
531 {
533 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
534 writeln!(f, "{{ not json").unwrap();
535 }
536 let mut other = ProofIntent::new(
537 "blake3:zzz",
538 "/data/projects/bar",
539 None,
540 "pooled",
541 IncidentReasonCode::ProofRefusal,
542 StaleSourcePolicy::AllowIfChanged,
543 ReplayConstraints::default(),
544 1_700_000_000_001,
545 );
546 other.requested_worker = Some("css".to_string());
547 store.put(&other).unwrap();
548
549 assert_eq!(store.all().len(), 2);
550 assert_eq!(store.by_reason(IncidentReasonCode::ProofRefusal).len(), 2);
551 assert!(store.get(&other.intent_id).is_some());
552 assert!(store.get("pi-nonexistent").is_none());
553 }
554
555 #[test]
556 fn missing_store_reads_empty() {
557 let dir = tempfile::tempdir().unwrap();
558 let store = ProofIntentStore::with_path(dir.path().join("nope.jsonl"));
559 assert!(store.all().is_empty());
560 }
561}