1use serde_json::{Value, json};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use super::audit_trail::AuditEntry;
18use super::canonical::{canonical_serialize, sign_manifest};
19
20const SIGNING_AGENT: &str = "lean-ctx";
21
22pub struct BundleSpec {
23 pub from: String,
25 pub to: String,
27 pub pack: Option<String>,
30 pub out: Option<PathBuf>,
31}
32
33#[derive(Debug)]
34pub struct BundleResult {
35 pub path: PathBuf,
36 pub sha256: String,
37 pub entries: usize,
38 pub files: Vec<String>,
39}
40
41pub fn generate_artifact_bundle(
47 out: &Path,
48 mut files: Vec<(String, Vec<u8>)>,
49 run_metadata: &Value,
50 signing_key: &super::canonical::AgentIdentity,
51) -> Result<BundleResult, String> {
52 if files.is_empty() {
53 return Err("evidence artifact bundle requires at least one file".to_string());
54 }
55
56 files.sort_by(|left, right| left.0.cmp(&right.0));
57 let mut seen_paths = std::collections::BTreeSet::new();
58 for (path, _) in &files {
59 if path == "manifest.json" || !safe_artifact_path(path) {
60 return Err(format!("unsafe evidence artifact path: {path}"));
61 }
62 if !seen_paths.insert(path) {
63 return Err(format!("duplicate evidence artifact path: {path}"));
64 }
65 }
66
67 let project = std::env::current_dir()
68 .ok()
69 .and_then(|dir| {
70 dir.file_name()
71 .map(|name| name.to_string_lossy().into_owned())
72 })
73 .unwrap_or_else(|| "unknown".to_string());
74 let file_hashes: Vec<Value> = files
75 .iter()
76 .map(|(path, bytes)| json!({ "path": path, "sha256": sha256_hex(bytes) }))
77 .collect();
78 let public_key =
79 crate::core::agent_identity::hex_encode(signing_key.verifying_key().as_bytes());
80 let mut manifest = json!({
81 "bundle": "evidence-bundle",
82 "version": 1,
83 "subject": { "agent_id": SIGNING_AGENT, "project": project },
84 "run": run_metadata,
85 "files": file_hashes,
86 "signing": {
87 "algorithm": "ed25519",
88 "public_key": public_key,
89 "signed_digest": "",
90 "signature": "",
91 },
92 });
93
94 let digest = sha256_hex(&canonical_serialize(&manifest));
97 manifest["signing"]["signed_digest"] = Value::String(digest.clone());
98 let signature = crate::core::agent_identity::hex_encode(
99 &crate::core::agent_identity::sign_bytes_with(signing_key, digest.as_bytes()),
100 );
101 manifest["signing"]["signature"] = Value::String(signature);
102
103 files.push(("manifest.json".to_string(), canonical_serialize(&manifest)));
104 files.sort_by(|left, right| left.0.cmp(&right.0));
105
106 let mut bytes = Vec::new();
107 {
108 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut bytes));
109 let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
110 .compression_method(zip::CompressionMethod::Stored)
111 .last_modified_time(zip::DateTime::default());
112 for (path, contents) in &files {
113 zip.start_file(path, options)
114 .map_err(|error| error.to_string())?;
115 zip.write_all(contents).map_err(|error| error.to_string())?;
116 }
117 zip.finish().map_err(|error| error.to_string())?;
118 }
119 std::fs::write(out, &bytes).map_err(|error| format!("write {}: {error}", out.display()))?;
120
121 Ok(BundleResult {
122 path: out.to_path_buf(),
123 sha256: sha256_hex(&bytes),
124 entries: files.len() - 1,
125 files: files.into_iter().map(|(path, _)| path).collect(),
126 })
127}
128
129fn safe_artifact_path(path: &str) -> bool {
130 let path = Path::new(path);
131 !path.is_absolute()
132 && !path
133 .components()
134 .any(|component| matches!(component, std::path::Component::ParentDir))
135}
136
137fn sha256_hex(bytes: &[u8]) -> String {
138 use sha2::{Digest, Sha256};
139 let mut hasher = Sha256::new();
140 hasher.update(bytes);
141 crate::core::agent_identity::hex_encode(&hasher.finalize())
142}
143
144pub type AssessFrameworks =
148 fn(
149 &crate::core::compliance_evidence::facts::ComplianceFactsV1,
150 ) -> Result<crate::core::compliance_evidence::frameworks::FrameworksAnswer, String>;
151
152pub fn generate(spec: &BundleSpec) -> Result<BundleResult, String> {
153 generate_with(spec, None)
154}
155
156pub fn generate_with(
159 spec: &BundleSpec,
160 framework: Option<(&str, AssessFrameworks)>,
161) -> Result<BundleResult, String> {
162 let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
163 .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
164 let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
165 .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
166 if from > to {
167 return Err("--from must not be after --to".to_string());
168 }
169
170 let trail_path = crate::core::data_dir::lean_ctx_data_dir()
172 .map_err(|e| format!("data dir: {e}"))?
173 .join("audit")
174 .join("trail.jsonl");
175 let trail_raw = crate::core::audit_trail::read_trail_since(&trail_path, Some(from.to_utc()))
177 .map_err(|e| format!("no audit trail at {}: {e}", trail_path.display()))?
178 .ok_or_else(|| format!("no audit trail at {}", trail_path.display()))?;
179
180 let mut segment_lines: Vec<String> = Vec::new();
181 let mut anchor_prev_hash: Option<String> = None;
182 let mut head_hash = String::new();
183 for (lineno, line) in trail_raw.lines().enumerate() {
184 let mut stream = serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>();
191 let mut parsed_any = false;
192 loop {
193 let value = match stream.next() {
194 None => break,
195 Some(Ok(v)) => v,
196 Some(Err(e)) => {
197 if segment_lines.is_empty() && !parsed_any {
198 break; }
200 return Err(format!(
201 "corrupt trail line {} inside the attested period: {e}",
202 lineno + 1
203 ));
204 }
205 };
206 parsed_any = true;
207 let entry: AuditEntry = match serde_json::from_value(value) {
208 Ok(e) => e,
209 Err(e) => {
210 if segment_lines.is_empty() {
211 continue;
212 }
213 return Err(format!("malformed audit entry at line {}: {e}", lineno + 1));
214 }
215 };
216 let ts = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
217 .map_err(|e| format!("corrupt trail timestamp at line {}: {e}", lineno + 1))?;
218 if ts < from || ts > to {
219 continue;
220 }
221 if anchor_prev_hash.is_none() {
222 anchor_prev_hash = Some(entry.prev_hash.clone());
223 }
224 head_hash.clone_from(&entry.entry_hash);
225 segment_lines.push(serde_json::to_string(&entry).map_err(|e| e.to_string())?);
228 }
229 }
230 if segment_lines.is_empty() {
231 return Err(format!(
232 "no audit entries between {} and {} — nothing to attest",
233 spec.from, spec.to
234 ));
235 }
236 let audit_jsonl = format!("{}\n", segment_lines.join("\n"));
237 let anchor_prev_hash = anchor_prev_hash.expect("non-empty segment");
238
239 let pack_name = spec.pack.clone().unwrap_or_else(|| {
241 if Path::new(".lean-ctx/policy.toml").exists() {
242 ".lean-ctx/policy.toml".to_string()
243 } else {
244 "baseline".to_string()
245 }
246 });
247 let pack = if Path::new(&pack_name)
248 .extension()
249 .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
250 {
251 crate::core::policy::parse_file(Path::new(&pack_name))
252 .map_err(|e| format!("pack {pack_name}: {e}"))?
253 } else {
254 crate::core::policy::builtin::get(&pack_name)
255 .ok_or_else(|| crate::core::policy::builtin::unknown_pack_hint(&pack_name))?
256 };
257 let resolved =
258 crate::core::policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))?;
259 let resolved_value = serde_json::to_value(&resolved).map_err(|e| e.to_string())?;
260 let policy_file = (
261 format!("policies/{}.resolved.json", resolved.name),
262 canonical_serialize(&resolved_value),
263 );
264
265 let cgb_checks = crate::core::policy::coverage::assess(&resolved);
267 let cgb_doc = json!({
268 "benchmark": crate::core::policy::coverage::BENCHMARK_ID,
269 "pack": { "name": resolved.name, "version": resolved.version },
270 "checks": serde_json::to_value(&cgb_checks).map_err(|e| e.to_string())?,
271 "summary": serde_json::to_value(crate::core::policy::coverage::summarize(&cgb_checks))
272 .map_err(|e| e.to_string())?,
273 });
274 let mut files: Vec<(String, Vec<u8>)> = vec![
275 ("audit/trail.jsonl".to_string(), audit_jsonl.into_bytes()),
276 policy_file,
277 (
278 "coverage/cgb.json".to_string(),
279 canonical_serialize(&cgb_doc),
280 ),
281 ];
282
283 if let Some((id, assess)) = framework {
284 let mut answer = crate::core::compliance_evidence::assess_frameworks(
285 &[id.to_string()],
286 Some(&resolved),
287 assess,
288 )?;
289 let report = answer.frameworks.remove(0);
290 let value = serde_json::to_value(&report).map_err(|e| e.to_string())?;
291 files.push((format!("coverage/{id}.json"), canonical_serialize(&value)));
292 }
293
294 files.sort_by(|a, b| a.0.cmp(&b.0));
295
296 let project = std::env::current_dir()
298 .ok()
299 .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
300 .unwrap_or_else(|| "unknown".to_string());
301
302 let file_hashes: Vec<Value> = files
303 .iter()
304 .map(|(path, bytes)| json!({ "path": path, "sha256": sha256_hex(bytes) }))
305 .collect();
306
307 let signing_key = crate::core::agent_identity::get_or_create_keypair(SIGNING_AGENT)
311 .map_err(|e| format!("signing identity: {e}"))?;
312 let public_key =
313 crate::core::agent_identity::hex_encode(signing_key.verifying_key().as_bytes());
314
315 let mut manifest = json!({
316 "bundle": "evidence-bundle",
317 "version": 1,
318 "period": { "from": spec.from, "to": spec.to },
319 "subject": { "agent_id": SIGNING_AGENT, "project": project },
320 "framework": framework.map(|(id, _)| id),
322 "files": file_hashes,
323 "chain": {
324 "entries": segment_lines.len(),
325 "anchor_prev_hash": anchor_prev_hash,
326 "head_hash": head_hash,
327 },
328 "signing": {
329 "algorithm": "ed25519",
330 "public_key": public_key,
331 "signed_digest": "",
332 "signature": "",
333 }
334 });
335
336 let digest = sha256_hex(&canonical_serialize(&manifest));
337 manifest["signing"]["signed_digest"] = Value::String(digest);
338 let signature =
339 sign_manifest(&manifest, &signing_key).map_err(|e| format!("manifest signature: {e}"))?;
340 manifest["signing"]["signature"] = Value::String(signature);
341
342 files.insert(
344 0,
345 ("manifest.json".to_string(), canonical_serialize(&manifest)),
346 );
347 files.sort_by(|a, b| a.0.cmp(&b.0));
348
349 let out_path = spec.out.clone().unwrap_or_else(|| {
351 PathBuf::from(format!(
352 "evidence-bundle_{}_{}.zip",
353 spec.from.replace(':', ""),
354 spec.to.replace(':', "")
355 ))
356 });
357 let mut buf: Vec<u8> = Vec::new();
358 {
359 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
360 let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
361 .compression_method(zip::CompressionMethod::Stored)
362 .last_modified_time(zip::DateTime::default());
363 for (path, bytes) in &files {
364 zip.start_file(path, options).map_err(|e| e.to_string())?;
365 zip.write_all(bytes).map_err(|e| e.to_string())?;
366 }
367 zip.finish().map_err(|e| e.to_string())?;
368 }
369 std::fs::write(&out_path, &buf).map_err(|e| format!("write {}: {e}", out_path.display()))?;
370
371 Ok(BundleResult {
372 path: out_path,
373 sha256: sha256_hex(&buf),
374 entries: segment_lines.len(),
375 files: files.into_iter().map(|(p, _)| p).collect(),
376 })
377}