1use crate::{
3 CtlError,
4 history::{FeedbackSource, TaskReport, valid_id},
5};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::{io::Read, path::Path};
9
10#[derive(Debug, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "snake_case")]
12pub enum StatementKind {
13 Fact,
14 Inference,
15 UserFeedback,
16}
17#[derive(Debug, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "snake_case")]
19pub enum EvidenceKind {
20 LocalFile,
21 ExternalReference,
22}
23#[derive(Debug, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct EvidenceRef {
26 pub id: String,
27 pub kind: EvidenceKind,
28 pub reference: String,
29}
30
31pub fn validate(report: &TaskReport) -> Result<(), CtlError> {
32 let mut ids = std::collections::HashSet::new();
33 for artifact in &report.artifacts {
34 if !valid_id(&artifact.id)
35 || !ids.insert(&artifact.id)
36 || artifact.reference.is_empty()
37 || artifact.reference.len() > 4096
38 {
39 return Err(CtlError::protocol(
40 "artifact requires unique ID and bounded reference",
41 ));
42 }
43 if artifact.kind == EvidenceKind::LocalFile
44 && (!Path::new(&artifact.reference).is_absolute()
45 || artifact.reference.starts_with("\\\\")
46 || artifact.reference.starts_with("//"))
47 {
48 return Err(CtlError::protocol(
49 "local evidence requires an absolute non-UNC path; use external_reference otherwise",
50 ));
51 }
52 }
53 for item in &report.feedback {
54 if item.evidence_ids.iter().any(|id| !ids.contains(id)) {
55 return Err(CtlError::protocol(
56 "feedback references unknown artifact ID",
57 ));
58 }
59 match item.kind {
60 Some(StatementKind::Fact) if !item.verified || item.evidence_ids.is_empty() => {
61 return Err(CtlError::protocol(
62 "facts require caller verification and evidence IDs",
63 ));
64 }
65 Some(StatementKind::Inference) if item.verified => {
66 return Err(CtlError::protocol("inference cannot be marked verified"));
67 }
68 Some(StatementKind::UserFeedback) if !matches!(item.source, FeedbackSource::User) => {
69 return Err(CtlError::protocol("user_feedback requires source=user"));
70 }
71 _ => {}
72 }
73 }
74 Ok(())
75}
76
77fn availability(artifact: &EvidenceRef) -> &'static str {
78 if artifact.kind == EvidenceKind::ExternalReference {
79 return "unchecked";
80 }
81 match std::fs::metadata(&artifact.reference) {
82 Ok(m) if m.is_file() => {
83 if std::fs::File::open(&artifact.reference).is_ok() {
84 "available"
85 } else {
86 "unreadable"
87 }
88 }
89 Ok(_) => "not_a_file",
90 Err(e) if e.kind() == std::io::ErrorKind::NotFound => "missing",
91 Err(_) => "unreadable",
92 }
93}
94pub fn read(root: &Path, task: &str, limit: usize) -> Result<Value, CtlError> {
95 let dir = root.join("tasks");
96 if !dir.exists() {
97 return Ok(json!([]));
98 }
99 let mut files = vec![];
100 for entry in std::fs::read_dir(dir).map_err(|e| CtlError::internal(e.to_string()))? {
101 let entry = entry.map_err(|e| CtlError::internal(e.to_string()))?;
102 if entry.file_type().is_ok_and(|t| t.is_file())
103 && entry
104 .file_name()
105 .to_string_lossy()
106 .starts_with(&format!("{task}-"))
107 {
108 files.push((
109 entry.metadata().and_then(|m| m.modified()).ok(),
110 entry.path(),
111 ));
112 }
113 }
114 files.sort_by_key(|f| std::cmp::Reverse(f.0));
115 let mut result = vec![];
116 for (_, path) in files {
117 if result.len() == limit {
118 break;
119 }
120 let mut bytes = vec![];
121 std::fs::File::open(&path)
122 .and_then(|f| f.take(131073).read_to_end(&mut bytes))
123 .map_err(|e| CtlError::internal(e.to_string()))?;
124 if bytes.len() > 131072 {
125 return Err(CtlError::protocol("archived report exceeds read limit"));
126 }
127 let mut doc: Value =
128 serde_json::from_slice(&bytes).map_err(|e| CtlError::protocol(e.to_string()))?;
129 if doc["report"]["task_id"].as_str() != Some(task) {
130 continue;
131 }
132 let report: TaskReport = serde_json::from_value(doc["report"].clone())
133 .map_err(|e| CtlError::protocol(e.to_string()))?;
134 validate(&report)?;
135 doc["evidence_status"] = json!(report.artifacts.iter().map(|a| json!({"id":a.id,"availability":availability(a),"checked_ms":crate::state::unix_ms()})).collect::<Vec<_>>());
136 doc["archive_path"] = json!(path);
137 result.push(doc);
138 }
139 Ok(json!(result))
140}