use std::io::{Cursor, Write};
use std::path::Path;
use serde::Serialize;
use time::{Date, Month};
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
use crate::error::Result;
use crate::redact;
#[derive(Debug, Serialize)]
pub struct ManifestFile {
pub name: String,
pub bytes: u64,
}
#[derive(Debug, Serialize)]
pub struct Manifest {
pub schema: u32,
pub service: String,
pub service_version: String,
pub os: String,
pub arch: String,
pub created_at: String,
pub redaction_rules_version: u32,
pub files: Vec<ManifestFile>,
}
pub struct SourceFile {
pub name: String,
pub contents: String,
}
pub fn build(
service: &str,
service_version: &str,
created_at: &str,
sources: &[SourceFile],
) -> Result<Vec<u8>> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut manifest_files = Vec::with_capacity(sources.len());
for source in sources {
let redacted = redact::text(&source.contents);
zip.start_file(&source.name, options)?;
zip.write_all(redacted.as_bytes())?;
manifest_files.push(ManifestFile {
name: source.name.clone(),
bytes: redacted.len() as u64,
});
}
let manifest = Manifest {
schema: 1,
service: service.to_string(),
service_version: service_version.to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
created_at: created_at.to_string(),
redaction_rules_version: redact::RULES_VERSION,
files: manifest_files,
};
zip.start_file("manifest.json", options)?;
zip.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
Ok(zip.finish()?.into_inner())
}
pub fn read_service_dir(dir: &Path, service: &str, since: Option<Date>) -> Vec<SourceFile> {
let prefix = format!("{service}.jsonl");
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|dirent| {
let path = dirent.path();
let name = path.file_name()?.to_string_lossy().into_owned();
if !name.starts_with(&prefix) || !within_since(&name, since) {
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
Some(SourceFile { name, contents })
})
.collect()
}
fn within_since(name: &str, since: Option<Date>) -> bool {
match (since, file_date(name)) {
(Some(cutoff), Some(date)) => date >= cutoff,
_ => true,
}
}
fn file_date(name: &str) -> Option<Date> {
let tail = name.rsplit('.').next()?;
let mut parts = tail.split('-');
let year: i32 = parts.next()?.parse().ok()?;
let month: u8 = parts.next()?.parse().ok()?;
let day: u8 = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None; }
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn bundle_round_trips_redacted_with_manifest() {
let seed =
"abandon ability able about above absent absorb abstract absurd abuse access accident";
let sources = vec![SourceFile {
name: "dig-node.jsonl.2026-07-16".into(),
contents: format!(r#"{{"message":"seed {seed}","store":"abc123"}}"#),
}];
let bytes = build("dig-node", "1.0.0", "2026-07-16T00:00:00Z", &sources).unwrap();
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut manifest = String::new();
archive
.by_name("manifest.json")
.unwrap()
.read_to_string(&mut manifest)
.unwrap();
assert!(manifest.contains("\"redaction_rules_version\": 7"));
let mut log = String::new();
archive
.by_name("dig-node.jsonl.2026-07-16")
.unwrap()
.read_to_string(&mut log)
.unwrap();
assert!(!log.contains("abandon"), "secret must be absent: {log}");
assert!(log.contains("[REDACTED:mnemonic]"));
assert!(log.contains("abc123"), "store id kept");
}
#[test]
fn within_since_filters_by_rotation_date() {
let cutoff = Date::from_calendar_date(2026, Month::July, 14).unwrap();
assert!(within_since("dig-node.jsonl.2026-07-16", Some(cutoff)));
assert!(within_since("dig-node.jsonl.2026-07-14", Some(cutoff)));
assert!(!within_since("dig-node.jsonl.2026-07-10", Some(cutoff)));
assert!(within_since("dig-node.jsonl.2026-07-10", None));
assert!(within_since("dig-node.jsonl", Some(cutoff)));
}
}