use crate::error::{EngramError, Result};
use super::types::{AttestationRecord, MerkleProof};
pub fn export_json(records: &[AttestationRecord]) -> Result<String> {
serde_json::to_string_pretty(records).map_err(EngramError::Serialization)
}
pub fn export_csv(records: &[AttestationRecord]) -> Result<String> {
let mut csv = String::from(
"id,document_hash,document_name,document_size,ingested_at,\
agent_id,memory_ids,previous_hash,record_hash,signature\n",
);
for r in records {
let memory_ids_str =
serde_json::to_string(&r.memory_ids).unwrap_or_else(|_| "[]".to_string());
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{}\n",
r.id.map(|id| id.to_string()).unwrap_or_default(),
escape_csv(&r.document_hash),
escape_csv(&r.document_name),
r.document_size,
r.ingested_at.to_rfc3339(),
escape_csv(r.agent_id.as_deref().unwrap_or("")),
escape_csv(&memory_ids_str),
escape_csv(&r.previous_hash),
escape_csv(&r.record_hash),
escape_csv(r.signature.as_deref().unwrap_or("")),
));
}
Ok(csv)
}
pub fn export_merkle_proof(proof: &MerkleProof) -> Result<String> {
serde_json::to_string_pretty(proof).map_err(EngramError::Serialization)
}
fn escape_csv(s: &str) -> String {
let needs_quote = s.contains(',')
|| s.contains('"')
|| s.contains('\n')
|| s.contains('\r')
|| matches!(s.chars().next(), Some('=' | '+' | '-' | '@'));
if needs_quote {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::types::MerkleProof;
use chrono::Utc;
fn sample_record() -> AttestationRecord {
AttestationRecord {
id: Some(1),
document_hash: "sha256:abc".to_string(),
document_name: "report.txt".to_string(),
document_size: 42,
ingested_at: Utc::now(),
agent_id: Some("agent-1".to_string()),
memory_ids: vec![10, 20],
previous_hash: "genesis".to_string(),
record_hash: "sha256:def".to_string(),
signature: None,
metadata: serde_json::json!({}),
created_at: None,
}
}
#[test]
fn test_export_json_roundtrip() {
let record = sample_record();
let json = export_json(std::slice::from_ref(&record)).unwrap();
let parsed: Vec<AttestationRecord> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].document_name, "report.txt");
}
#[test]
fn test_export_csv_has_header() {
let csv = export_csv(&[]).unwrap();
assert!(csv.starts_with("id,document_hash,document_name"));
}
#[test]
fn test_export_csv_record() {
let record = sample_record();
let csv = export_csv(&[record]).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[1].contains("report.txt"));
assert!(lines[1].contains("sha256:abc"));
}
#[test]
fn test_export_csv_escapes_commas() {
let mut record = sample_record();
record.document_name = "report, final.txt".to_string();
let csv = export_csv(&[record]).unwrap();
assert!(csv.contains("\"report, final.txt\""));
}
#[test]
fn test_export_merkle_proof() {
let proof = MerkleProof {
leaf_hash: "sha256:aaa".to_string(),
leaf_index: 0,
proof_hashes: vec![("sha256:bbb".to_string(), true)],
root_hash: "sha256:ccc".to_string(),
total_leaves: 2,
scheme_version: 2,
};
let json = export_merkle_proof(&proof).unwrap();
assert!(json.contains("leaf_hash"));
assert!(json.contains("sha256:aaa"));
}
#[test]
fn test_escape_csv_no_special_chars() {
assert_eq!(escape_csv("plain"), "plain");
}
#[test]
fn test_escape_csv_with_comma() {
assert_eq!(escape_csv("a,b"), "\"a,b\"");
}
#[test]
fn test_escape_csv_with_quote() {
assert_eq!(escape_csv("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn test_export_csv_agent_id_formula_escaped() {
let mut record = sample_record();
record.agent_id = Some("=HYPERLINK(\"http://evil.com\")".to_string());
let csv = export_csv(&[record]).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert!(
!lines[1].contains(",=HYPERLINK"),
"formula injection: agent_id starting with = must be escaped; got: {}",
lines[1]
);
}
#[test]
fn test_export_csv_agent_id_plus_escaped() {
let mut record = sample_record();
record.agent_id = Some("+cmd|' /C calc'!A0".to_string());
let csv = export_csv(&[record]).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert!(
!lines[1].contains(",+cmd"),
"formula injection: agent_id starting with + must be escaped; got: {}",
lines[1]
);
}
#[test]
fn test_export_csv_record_hash_formula_escaped() {
let mut record = sample_record();
record.record_hash = "=malicious()".to_string();
let csv = export_csv(&[record]).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert!(
!lines[1].contains(",=malicious()"),
"record_hash formula injection must be escaped; got: {}",
lines[1]
);
}
#[test]
fn test_escape_csv_with_carriage_return_is_quoted() {
let result = escape_csv("line1\rline2");
assert!(
result.starts_with('"') && result.ends_with('"'),
"value with \\r must be quoted, got: {result:?}"
);
}
#[test]
fn test_export_csv_document_name_with_cr_is_safe() {
let mut record = sample_record();
record.document_name = "report\rinjected".to_string();
let csv = export_csv(&[record]).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(
lines.len(),
2,
"\\r in document_name must not produce extra CSV rows; got lines: {lines:?}"
);
}
#[test]
fn test_export_csv_signature_formula_escaped() {
let mut record = sample_record();
record.signature = Some("-2+3+cmd|' /C calc'!A0".to_string());
let csv = export_csv(&[record]).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert!(
!lines[1].contains(",-2+3"),
"signature formula injection must be escaped; got: {}",
lines[1]
);
}
}