1use std::collections::HashSet;
17use std::path::{Component, Path, PathBuf};
18
19use chrono::Utc;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use walkdir::WalkDir;
23
24use crate::error::SessionStoreError;
25use crate::{ensure_private_directory, session_dir};
26
27const AUDIT_PACK_FILE_NAME: &str = "audit-pack.json";
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct AuditPackEntry {
34 pub path: String,
37 pub bytes: u64,
39 pub sha256: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct SessionAuditPack {
46 pub schema_version: u32,
48 pub session_id: String,
50 pub generated_at: String,
52 pub status: String,
54 pub turn_count: u64,
56 pub event_count: u64,
58 pub entries: Vec<AuditPackEntry>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct AuditVerification {
65 pub verified: bool,
67 pub mismatches: Vec<String>,
69 pub missing: Vec<String>,
71 pub unaccounted: Vec<String>,
74}
75
76pub const AUDIT_PACK_SCHEMA_VERSION: u32 = 1;
78
79#[must_use]
82pub fn audit_pack_path(workspace: &Path, session_id: &str) -> PathBuf {
83 session_dir(workspace, session_id)
84 .join(crate::DERIVED_DIR)
85 .join(AUDIT_PACK_FILE_NAME)
86}
87
88pub fn create_audit_pack(workspace: &Path, session_id: &str) -> Result<SessionAuditPack, SessionStoreError> {
94 let dir = session_dir(workspace, session_id);
95 let manifest_path = dir.join("manifest.json");
96 let manifest_bytes =
97 std::fs::read(&manifest_path).map_err(|error| SessionStoreError::io(manifest_path.clone(), error))?;
98 let summary: crate::query::SessionSummary = serde_json::from_slice(&manifest_bytes)?;
99
100 let mut entries = Vec::new();
101 for file in walk_files(&dir)? {
102 let relative = file
103 .strip_prefix(&dir)
104 .map_err(|error| SessionStoreError::io(file.clone(), std::io::Error::other(error)))?
105 .components()
106 .map(|component| component.as_os_str().to_string_lossy())
107 .collect::<Vec<_>>()
108 .join("/");
109 let (bytes, sha256) = digest_file(&file)?;
110 entries.push(AuditPackEntry { path: relative, bytes, sha256 });
111 }
112 entries.sort_by(|a, b| a.path.cmp(&b.path));
113
114 Ok(SessionAuditPack {
115 schema_version: AUDIT_PACK_SCHEMA_VERSION,
116 session_id: session_id.to_string(),
117 generated_at: Utc::now().to_rfc3339(),
118 status: summary.status,
119 turn_count: summary.turn_count,
120 event_count: summary.event_count,
121 entries,
122 })
123}
124
125pub fn write_audit_pack(
133 workspace: &Path,
134 session_id: &str,
135 output: Option<&Path>,
136) -> Result<(SessionAuditPack, PathBuf), SessionStoreError> {
137 let pack = create_audit_pack(workspace, session_id)?;
138 let destination = output.map_or_else(|| audit_pack_path(workspace, session_id), Path::to_path_buf);
139 if let Some(parent) = destination.parent() {
140 ensure_private_directory(parent)?;
141 }
142 let bytes = serde_json::to_vec_pretty(&pack)?;
143 vtcode_commons::VtCodePaths::write_private_file_atomic(&destination, &bytes)
144 .map_err(|error| SessionStoreError::io(destination.clone(), std::io::Error::other(error)))?;
145 Ok((pack, destination))
146}
147
148pub fn read_audit_pack(path: &Path) -> Result<SessionAuditPack, SessionStoreError> {
155 let bytes = std::fs::read(path).map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
156 let pack: SessionAuditPack = serde_json::from_slice(&bytes)?;
157 if pack.schema_version != AUDIT_PACK_SCHEMA_VERSION {
158 return Err(SessionStoreError::InvalidPack(format!(
159 "unsupported schema_version {} (expected {AUDIT_PACK_SCHEMA_VERSION})",
160 pack.schema_version
161 )));
162 }
163 Ok(pack)
164}
165
166pub fn verify_audit_pack(
176 workspace: &Path,
177 session_id: &str,
178 pack: &SessionAuditPack,
179) -> Result<AuditVerification, SessionStoreError> {
180 let dir = session_dir(workspace, session_id);
181 let mut mismatches = Vec::new();
182 let mut missing = Vec::new();
183 let mut listed = HashSet::new();
184
185 for entry in &pack.entries {
186 validate_entry_path(&entry.path)?;
187 listed.insert(entry.path.clone());
188 let absolute = dir.join(&entry.path);
189 let (bytes, sha256) = match digest_file(&absolute) {
190 Ok(digest) => digest,
191 Err(error) if matches!(&error, SessionStoreError::Io { .. } if is_not_found(&error)) => {
192 missing.push(entry.path.clone());
193 continue;
194 }
195 Err(error) => return Err(error),
196 };
197 if bytes != entry.bytes || sha256 != entry.sha256 {
198 mismatches.push(entry.path.clone());
199 }
200 }
201
202 let mut unaccounted = Vec::new();
203 for file in walk_files(&dir)? {
204 let relative = file
205 .strip_prefix(&dir)
206 .map(|relative| {
207 relative
208 .components()
209 .map(|component| component.as_os_str().to_string_lossy())
210 .collect::<Vec<_>>()
211 .join("/")
212 })
213 .map_err(|error| SessionStoreError::io(file.clone(), std::io::Error::other(error)))?;
214 if !listed.contains(&relative) {
215 unaccounted.push(relative);
216 }
217 }
218 unaccounted.sort();
219
220 Ok(AuditVerification {
221 verified: mismatches.is_empty() && missing.is_empty(),
222 mismatches,
223 missing,
224 unaccounted,
225 })
226}
227
228fn walk_files(dir: &Path) -> Result<Vec<PathBuf>, SessionStoreError> {
231 let mut files = Vec::new();
232 for entry in WalkDir::new(dir).sort_by_file_name() {
233 let entry = entry.map_err(|error| SessionStoreError::io(dir.to_path_buf(), std::io::Error::other(error)))?;
234 if !entry.file_type().is_file() {
235 continue;
236 }
237 if entry.file_name().to_string_lossy() == AUDIT_PACK_FILE_NAME {
238 continue;
239 }
240 files.push(entry.into_path());
241 }
242 Ok(files)
243}
244
245fn digest_file(path: &Path) -> Result<(u64, String), SessionStoreError> {
247 use std::io::Read;
248
249 let mut file = std::fs::File::open(path).map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
250 let mut hasher = Sha256::new();
251 let mut bytes = 0u64;
252 let mut buffer = vec![0u8; 64 * 1024];
253 loop {
254 let read = file
255 .read(&mut buffer)
256 .map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
257 if read == 0 {
258 break;
259 }
260 match buffer.get(..read) {
261 Some(chunk) => hasher.update(chunk),
262 None => break,
264 }
265 bytes += u64::try_from(read).unwrap_or(u64::MAX);
266 }
267 let hex = hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>();
268 Ok((bytes, hex))
269}
270
271fn validate_entry_path(relative: &str) -> Result<(), SessionStoreError> {
273 if relative.is_empty() {
274 return Err(SessionStoreError::InvalidPack("entry path is empty".to_string()));
275 }
276 let path = Path::new(relative);
277 if path.is_absolute() {
278 return Err(SessionStoreError::InvalidPack(format!("entry path {relative:?} is absolute")));
279 }
280 for component in path.components() {
281 match component {
282 Component::Normal(_) => {}
283 other => {
284 return Err(SessionStoreError::InvalidPack(format!(
285 "entry path {relative:?} contains a forbidden component ({other:?})"
286 )));
287 }
288 }
289 }
290 Ok(())
291}
292
293fn is_not_found(error: &SessionStoreError) -> bool {
294 matches!(
295 error,
296 SessionStoreError::Io { source, .. }
297 if source.kind() == std::io::ErrorKind::NotFound
298 )
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use tempfile::TempDir;
305
306 fn seed_session(workspace: &Path, session_id: &str) -> PathBuf {
309 let dir = session_dir(workspace, session_id);
310 ensure_private_directory(&dir).expect("session dir");
311 ensure_private_directory(&dir.join(crate::DERIVED_DIR)).expect("derived dir");
312 std::fs::write(
313 dir.join("manifest.json"),
314 serde_json::json!({
315 "session_id": session_id,
316 "schema_version": 1,
317 "created_at": "2026-01-01T00:00:00Z",
318 "updated_at": "2026-01-01T00:00:00Z",
319 "turn_count": 2,
320 "event_count": 7,
321 "status": "completed"
322 })
323 .to_string(),
324 )
325 .expect("manifest");
326 std::fs::write(dir.join("events.jsonl"), "{\"event\":1}\n{\"event\":2}\n").expect("events");
327 std::fs::write(dir.join(crate::DERIVED_DIR).join("memory.json"), "{\"facts\":[]}").expect("derived");
328 dir
329 }
330
331 #[test]
332 fn pack_round_trips_and_verifies_clean() {
333 let workspace = TempDir::new().expect("workspace");
334 seed_session(workspace.path(), "audit-a");
335
336 let pack = create_audit_pack(workspace.path(), "audit-a").expect("create pack");
337 let paths: Vec<&str> = pack.entries.iter().map(|entry| entry.path.as_str()).collect();
340 assert!(paths.contains(&"manifest.json"), "entries: {paths:?}");
341 assert!(paths.contains(&"events.jsonl"), "entries: {paths:?}");
342 assert!(paths.contains(&"derived/memory.json"), "entries: {paths:?}");
343
344 let bytes = serde_json::to_vec_pretty(&pack).expect("serialize");
346 let loaded: SessionAuditPack = serde_json::from_slice(&bytes).expect("deserialize");
347 assert_eq!(loaded, pack);
348
349 let report = verify_audit_pack(workspace.path(), "audit-a", &loaded).expect("verify");
350 assert!(report.verified, "fresh pack must verify: {report:?}");
351 assert!(report.mismatches.is_empty() && report.missing.is_empty() && report.unaccounted.is_empty());
352 assert_eq!(
353 report,
354 AuditVerification {
355 verified: true,
356 mismatches: Vec::new(),
357 missing: Vec::new(),
358 unaccounted: Vec::new(),
359 }
360 );
361 }
362
363 #[test]
364 fn single_byte_tamper_fails_verification() {
365 let workspace = TempDir::new().expect("workspace");
366 seed_session(workspace.path(), "audit-b");
367 let pack = create_audit_pack(workspace.path(), "audit-b").expect("create pack");
368
369 let events = workspace.path().join(".vtcode/sessions/audit-b/events.jsonl");
371 let contents = std::fs::read_to_string(&events).expect("read");
372 std::fs::write(&events, contents.replace("{\"event\":1}", "{\"event\":9}")).expect("tamper");
373
374 let report = verify_audit_pack(workspace.path(), "audit-b", &pack).expect("verify");
375 assert!(!report.verified);
376 assert_eq!(report.mismatches, vec!["events.jsonl".to_string()]);
377 assert!(report.missing.is_empty());
378 }
379
380 #[test]
381 fn appended_file_is_unaccounted_but_verifies() {
382 let workspace = TempDir::new().expect("workspace");
383 seed_session(workspace.path(), "audit-c");
384 let pack = create_audit_pack(workspace.path(), "audit-c").expect("create pack");
385
386 std::fs::write(workspace.path().join(".vtcode/sessions/audit-c/derived/progress.json"), "{}")
389 .expect("new file");
390
391 let report = verify_audit_pack(workspace.path(), "audit-c", &pack).expect("verify");
392 assert!(report.verified, "additions must not fail verification: {report:?}");
393 assert_eq!(report.unaccounted, vec!["derived/progress.json".to_string()]);
394 }
395
396 #[test]
397 fn deleted_file_is_reported_missing() {
398 let workspace = TempDir::new().expect("workspace");
399 seed_session(workspace.path(), "audit-d");
400 let pack = create_audit_pack(workspace.path(), "audit-d").expect("create pack");
401
402 std::fs::remove_file(workspace.path().join(".vtcode/sessions/audit-d/derived/memory.json"))
403 .expect("delete derived file");
404
405 let report = verify_audit_pack(workspace.path(), "audit-d", &pack).expect("verify");
406 assert!(!report.verified);
407 assert_eq!(report.missing, vec!["derived/memory.json".to_string()]);
408 assert!(report.mismatches.is_empty(), "missing must not double-report as mismatched");
409 }
410
411 #[test]
412 fn traversal_paths_in_loaded_packs_are_rejected() {
413 let workspace = TempDir::new().expect("workspace");
414 seed_session(workspace.path(), "audit-e");
415
416 let malicious = |path: &str| SessionAuditPack {
417 schema_version: AUDIT_PACK_SCHEMA_VERSION,
418 session_id: "audit-e".to_string(),
419 generated_at: "2026-01-01T00:00:00Z".to_string(),
420 status: "completed".to_string(),
421 turn_count: 0,
422 event_count: 0,
423 entries: vec![AuditPackEntry {
424 path: path.to_string(),
425 bytes: 1,
426 sha256: "0".repeat(64),
427 }],
428 };
429 for evil in ["../outside.json", "/etc/passwd", "derived/../../escape", ""] {
430 let error = verify_audit_pack(workspace.path(), "audit-e", &malicious(evil))
431 .expect_err("traversal pack must be rejected");
432 assert!(
433 matches!(error, SessionStoreError::InvalidPack(_)),
434 "path {evil:?} must be an InvalidPack error, got {error:?}"
435 );
436 }
437 }
438
439 #[test]
440 fn missing_session_fails_pack_creation() {
441 let workspace = TempDir::new().expect("workspace");
442 let error = create_audit_pack(workspace.path(), "ghost").expect_err("missing session");
443 assert!(matches!(error, SessionStoreError::Io { .. }));
444 }
445
446 #[test]
447 fn packs_are_deterministic_apart_from_timestamp() {
448 let workspace = TempDir::new().expect("workspace");
449 seed_session(workspace.path(), "audit-f");
450 let first = create_audit_pack(workspace.path(), "audit-f").expect("first");
451 let second = create_audit_pack(workspace.path(), "audit-f").expect("second");
452 assert_eq!(first.entries, second.entries, "file inventory must be stable and sorted");
453 let sorted: Vec<String> = second.entries.iter().map(|entry| entry.path.clone()).collect();
454 let mut expected = sorted.clone();
455 expected.sort();
456 assert_eq!(sorted, expected);
457 }
458
459 #[test]
460 fn write_and_read_round_trip_through_default_location() {
461 let workspace = TempDir::new().expect("workspace");
462 seed_session(workspace.path(), "audit-g");
463 let (pack, path) = write_audit_pack(workspace.path(), "audit-g", None).expect("write pack");
464 assert_eq!(path, audit_pack_path(workspace.path(), "audit-g"));
465
466 let loaded = read_audit_pack(&path).expect("read pack");
467 assert_eq!(loaded, pack);
468 assert_eq!(loaded.schema_version, AUDIT_PACK_SCHEMA_VERSION);
469 assert!(
471 loaded.entries.iter().all(|entry| !entry.path.ends_with(AUDIT_PACK_FILE_NAME)),
472 "pack must exclude itself: {:?}",
473 loaded.entries
474 );
475
476 let mut drifted = loaded.clone();
478 drifted.schema_version = 99;
479 std::fs::write(&path, serde_json::to_vec(&drifted).expect("serialize")).expect("write drifted");
480 let error = read_audit_pack(&path).expect_err("schema drift");
481 assert!(matches!(error, SessionStoreError::InvalidPack(_)));
482 }
483}