1use serde_json::{json, Value};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use super::audit_trail::AuditEntry;
18
19const SIGNING_AGENT: &str = "lean-ctx";
20
21pub struct BundleSpec {
22 pub from: String,
24 pub to: String,
26 pub framework: Option<String>,
28 pub pack: Option<String>,
31 pub out: Option<PathBuf>,
32}
33
34#[derive(Debug)]
35pub struct BundleResult {
36 pub path: PathBuf,
37 pub sha256: String,
38 pub entries: usize,
39 pub files: Vec<String>,
40}
41
42fn canonical_json(value: &Value) -> String {
46 serde_json::to_string(value).expect("canonical json")
47}
48
49fn sha256_hex(bytes: &[u8]) -> String {
50 use sha2::{Digest, Sha256};
51 let mut hasher = Sha256::new();
52 hasher.update(bytes);
53 format!("{:x}", hasher.finalize())
54}
55
56pub fn generate(spec: &BundleSpec) -> Result<BundleResult, String> {
59 let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
60 .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
61 let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
62 .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
63 if from > to {
64 return Err("--from must not be after --to".to_string());
65 }
66
67 let trail_path = crate::core::data_dir::lean_ctx_data_dir()
69 .map_err(|e| format!("data dir: {e}"))?
70 .join("audit")
71 .join("trail.jsonl");
72 let trail_raw = std::fs::read_to_string(&trail_path)
73 .map_err(|e| format!("no audit trail at {}: {e}", trail_path.display()))?;
74
75 let mut segment_lines: Vec<String> = Vec::new();
76 let mut anchor_prev_hash: Option<String> = None;
77 let mut head_hash = String::new();
78 for (lineno, line) in trail_raw.lines().enumerate() {
79 let mut stream = serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>();
86 let mut parsed_any = false;
87 loop {
88 let value = match stream.next() {
89 None => break,
90 Some(Ok(v)) => v,
91 Some(Err(e)) => {
92 if segment_lines.is_empty() && !parsed_any {
93 break; }
95 return Err(format!(
96 "corrupt trail line {} inside the attested period: {e}",
97 lineno + 1
98 ));
99 }
100 };
101 parsed_any = true;
102 let entry: AuditEntry = match serde_json::from_value(value) {
103 Ok(e) => e,
104 Err(e) => {
105 if segment_lines.is_empty() {
106 continue;
107 }
108 return Err(format!("malformed audit entry at line {}: {e}", lineno + 1));
109 }
110 };
111 let ts = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
112 .map_err(|e| format!("corrupt trail timestamp at line {}: {e}", lineno + 1))?;
113 if ts < from || ts > to {
114 continue;
115 }
116 if anchor_prev_hash.is_none() {
117 anchor_prev_hash = Some(entry.prev_hash.clone());
118 }
119 head_hash.clone_from(&entry.entry_hash);
120 segment_lines.push(serde_json::to_string(&entry).map_err(|e| e.to_string())?);
123 }
124 }
125 if segment_lines.is_empty() {
126 return Err(format!(
127 "no audit entries between {} and {} — nothing to attest",
128 spec.from, spec.to
129 ));
130 }
131 let audit_jsonl = format!("{}\n", segment_lines.join("\n"));
132 let anchor_prev_hash = anchor_prev_hash.expect("non-empty segment");
133
134 let pack_name = spec.pack.clone().unwrap_or_else(|| {
136 spec.framework
137 .as_deref()
138 .and_then(|fw| crate::core::compliance::get(fw))
139 .map_or_else(
140 || {
141 if Path::new(".lean-ctx/policy.toml").exists() {
142 ".lean-ctx/policy.toml".to_string()
143 } else {
144 "baseline".to_string()
145 }
146 },
147 |m| m.reference_pack.clone(),
148 )
149 });
150 let pack = if Path::new(&pack_name)
151 .extension()
152 .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
153 {
154 crate::core::policy::parse_file(Path::new(&pack_name))
155 .map_err(|e| format!("pack {pack_name}: {e}"))?
156 } else {
157 crate::core::policy::builtin::get(&pack_name)
158 .ok_or_else(|| format!("unknown builtin pack '{pack_name}'"))?
159 };
160 let resolved =
161 crate::core::policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))?;
162 let resolved_value = serde_json::to_value(&resolved).map_err(|e| e.to_string())?;
163 let policy_file = (
164 format!("policies/{}.resolved.json", resolved.name),
165 canonical_json(&resolved_value).into_bytes(),
166 );
167
168 let cgb_checks = crate::core::policy::coverage::assess(&resolved);
170 let cgb_doc = json!({
171 "benchmark": crate::core::policy::coverage::BENCHMARK_ID,
172 "pack": { "name": resolved.name, "version": resolved.version },
173 "checks": serde_json::to_value(&cgb_checks).map_err(|e| e.to_string())?,
174 "summary": serde_json::to_value(crate::core::policy::coverage::summarize(&cgb_checks))
175 .map_err(|e| e.to_string())?,
176 });
177 let mut files: Vec<(String, Vec<u8>)> = vec![
178 ("audit/trail.jsonl".to_string(), audit_jsonl.into_bytes()),
179 policy_file,
180 (
181 "coverage/cgb.json".to_string(),
182 canonical_json(&cgb_doc).into_bytes(),
183 ),
184 ];
185
186 if let Some(fw) = &spec.framework {
187 let mapping = crate::core::compliance::get(fw).ok_or_else(|| {
188 format!(
189 "unknown framework '{fw}' (supported: {})",
190 crate::core::compliance::names().join(", ")
191 )
192 })?;
193 let report = crate::core::compliance::report(mapping, Some(&resolved));
194 let value = serde_json::to_value(&report).map_err(|e| e.to_string())?;
195 files.push((
196 format!("coverage/{fw}.json"),
197 canonical_json(&value).into_bytes(),
198 ));
199 }
200
201 files.sort_by(|a, b| a.0.cmp(&b.0));
202
203 let project = std::env::current_dir()
205 .ok()
206 .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
207 .unwrap_or_else(|| "unknown".to_string());
208
209 let file_hashes: Vec<Value> = files
210 .iter()
211 .map(|(path, bytes)| json!({ "path": path, "sha256": sha256_hex(bytes) }))
212 .collect();
213
214 let signing_key = crate::core::agent_identity::get_or_create_keypair(SIGNING_AGENT)
218 .map_err(|e| format!("signing identity: {e}"))?;
219 let public_key =
220 crate::core::agent_identity::hex_encode(signing_key.verifying_key().as_bytes());
221
222 let mut manifest = json!({
223 "bundle": "evidence-bundle",
224 "version": 1,
225 "period": { "from": spec.from, "to": spec.to },
226 "subject": { "agent_id": SIGNING_AGENT, "project": project },
227 "framework": spec.framework,
228 "files": file_hashes,
229 "chain": {
230 "entries": segment_lines.len(),
231 "anchor_prev_hash": anchor_prev_hash,
232 "head_hash": head_hash,
233 },
234 "signing": {
235 "algorithm": "ed25519",
236 "public_key": public_key,
237 "signed_digest": "",
238 "signature": "",
239 }
240 });
241
242 let digest = sha256_hex(canonical_json(&manifest).as_bytes());
243 let signature = crate::core::agent_identity::hex_encode(
244 &crate::core::agent_identity::sign_bytes_with(&signing_key, digest.as_bytes()),
245 );
246 manifest["signing"]["signed_digest"] = Value::String(digest);
247 manifest["signing"]["signature"] = Value::String(signature);
248
249 files.insert(
251 0,
252 (
253 "manifest.json".to_string(),
254 canonical_json(&manifest).into_bytes(),
255 ),
256 );
257 files.sort_by(|a, b| a.0.cmp(&b.0));
258
259 let out_path = spec.out.clone().unwrap_or_else(|| {
261 PathBuf::from(format!(
262 "evidence-bundle_{}_{}.zip",
263 spec.from.replace(':', ""),
264 spec.to.replace(':', "")
265 ))
266 });
267 let mut buf: Vec<u8> = Vec::new();
268 {
269 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
270 let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
271 .compression_method(zip::CompressionMethod::Stored)
272 .last_modified_time(zip::DateTime::default());
273 for (path, bytes) in &files {
274 zip.start_file(path, options).map_err(|e| e.to_string())?;
275 zip.write_all(bytes).map_err(|e| e.to_string())?;
276 }
277 zip.finish().map_err(|e| e.to_string())?;
278 }
279 std::fs::write(&out_path, &buf).map_err(|e| format!("write {}: {e}", out_path.display()))?;
280
281 Ok(BundleResult {
282 path: out_path,
283 sha256: sha256_hex(&buf),
284 entries: segment_lines.len(),
285 files: files.into_iter().map(|(p, _)| p).collect(),
286 })
287}