use chrono::Utc;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::error::{EngramError, Result};
use crate::storage::Storage;
use super::types::{AttestationFilter, AttestationRecord, ChainStatus};
const GENESIS_HASH: &str = "genesis";
const MAX_DOCUMENT_BYTES: usize = 100 * 1024 * 1024;
const MAX_MEMORY_IDS: usize = 10_000;
pub struct AttestationChain {
storage: Storage,
}
impl AttestationChain {
pub fn new(storage: Storage) -> Self {
Self { storage }
}
pub fn log_document(
&self,
content: &[u8],
document_name: &str,
agent_id: Option<&str>,
memory_ids: &[i64],
sign_key: Option<&[u8; 32]>,
) -> Result<AttestationRecord> {
if document_name.trim().is_empty() {
return Err(EngramError::InvalidInput(
"document_name must not be empty".to_string(),
));
}
if document_name.len() > 1_000 {
return Err(EngramError::InvalidInput(
"document_name too long (max 1000 characters)".to_string(),
));
}
if document_name.contains('\0') {
return Err(EngramError::InvalidInput(
"document_name must not contain null bytes".to_string(),
));
}
if let Some(id) = agent_id {
if id.len() > 256 {
return Err(EngramError::InvalidInput(
"agent_id must not exceed 256 characters".to_string(),
));
}
if id.chars().any(|c| c.is_control()) {
return Err(EngramError::InvalidInput(
"agent_id must not contain control characters".to_string(),
));
}
}
if content.len() > MAX_DOCUMENT_BYTES {
return Err(EngramError::InvalidInput(format!(
"document too large: {} bytes (max {})",
content.len(),
MAX_DOCUMENT_BYTES
)));
}
if memory_ids.len() > MAX_MEMORY_IDS {
return Err(EngramError::InvalidInput(format!(
"too many memory_ids: {} (max {})",
memory_ids.len(),
MAX_MEMORY_IDS
)));
}
if memory_ids.iter().any(|&id| id < 0) {
return Err(EngramError::InvalidInput(
"memory_ids must be non-negative".to_string(),
));
}
let document_hash = hash_bytes(content);
let document_size = content.len();
let ingested_at = Utc::now();
let memory_ids_vec: Vec<i64> = memory_ids.to_vec();
let agent_id_owned = agent_id.map(str::to_string);
let sign_key_owned = sign_key.copied();
self.storage.with_transaction(|conn| {
let previous_hash: String = {
let mut stmt = conn
.prepare("SELECT record_hash FROM attestation_log ORDER BY id DESC LIMIT 1")?;
let mut rows = stmt.query([])?;
match rows.next()? {
Some(row) => row.get(0)?,
None => GENESIS_HASH.to_string(),
}
};
let mut record = AttestationRecord {
id: None,
document_hash,
document_name: document_name.to_string(),
document_size,
ingested_at,
agent_id: agent_id_owned,
memory_ids: memory_ids_vec,
previous_hash,
record_hash: String::new(),
signature: None,
metadata: serde_json::Value::Object(serde_json::Map::new()),
created_at: None,
};
record.record_hash = Self::compute_record_hash(&record);
if let Some(key_bytes) = sign_key_owned.as_ref() {
record.signature = Some(sign_record_hash(&record.record_hash, key_bytes)?);
}
let memory_ids_json =
serde_json::to_string(&record.memory_ids).map_err(EngramError::Serialization)?;
let metadata_json =
serde_json::to_string(&record.metadata).map_err(EngramError::Serialization)?;
const MAX_METADATA_BYTES: usize = 65_536;
if metadata_json.len() > MAX_METADATA_BYTES {
return Err(EngramError::InvalidInput(format!(
"metadata too large: {} bytes (max {})",
metadata_json.len(),
MAX_METADATA_BYTES
)));
}
conn.execute(
"INSERT INTO attestation_log
(document_hash, document_name, document_size, ingested_at,
agent_id, memory_ids, previous_hash, record_hash, signature, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
record.document_hash,
record.document_name,
record.document_size as i64,
record.ingested_at.to_rfc3339(),
record.agent_id,
memory_ids_json,
record.previous_hash,
record.record_hash,
record.signature,
metadata_json,
],
)?;
let id = conn.last_insert_rowid();
let created_at_str: String = conn.query_row(
"SELECT created_at FROM attestation_log WHERE id = ?1",
rusqlite::params![id],
|row| row.get(0),
)?;
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
.map(|dt| dt.with_timezone(&Utc))
.ok();
Ok(AttestationRecord {
id: Some(id),
created_at,
..record
})
})
}
pub fn verify_document(&self, content: &[u8]) -> Result<Option<AttestationRecord>> {
let hash = hash_bytes(content);
self.storage.with_connection(|conn| {
let mut stmt = conn.prepare(
"SELECT id, document_hash, document_name, document_size, ingested_at,
agent_id, memory_ids, previous_hash, record_hash, signature,
metadata, created_at
FROM attestation_log
WHERE document_hash = ?1
ORDER BY id ASC
LIMIT 1",
)?;
let mut rows = stmt.query(rusqlite::params![hash])?;
match rows.next()? {
Some(row) => Ok(Some(row_to_record(row)?)),
None => Ok(None),
}
})
}
pub fn verify_chain(&self, verifying_key: Option<&[u8; 32]>) -> Result<ChainStatus> {
self.storage.with_connection(|conn| {
let mut stmt = conn.prepare(
"SELECT id, document_hash, document_name, document_size, ingested_at,
agent_id, memory_ids, previous_hash, record_hash, signature,
metadata, created_at
FROM attestation_log
ORDER BY id ASC",
)?;
let mut expected_previous = GENESIS_HASH.to_string();
let mut record_count: usize = 0;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let record = row_to_record(row).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e.to_string(),
)),
)
})?;
record_count += 1;
let linkage_ok: bool = record
.previous_hash
.as_bytes()
.ct_eq(expected_previous.as_bytes())
.into();
if !linkage_ok {
return Ok(ChainStatus::Broken {
at_record_id: record.id.unwrap_or(-1),
expected_hash: expected_previous,
actual_hash: record.previous_hash.clone(),
});
}
let recomputed = Self::compute_record_hash(&record);
let hash_ok: bool = recomputed
.as_bytes()
.ct_eq(record.record_hash.as_bytes())
.into();
if !hash_ok {
return Ok(ChainStatus::Broken {
at_record_id: record.id.unwrap_or(-1),
expected_hash: recomputed,
actual_hash: record.record_hash.clone(),
});
}
if let Some(vk_bytes) = verifying_key {
let Some(sig_hex) = record.signature.as_deref() else {
return Ok(ChainStatus::Broken {
at_record_id: record.id.unwrap_or(-1),
expected_hash: format!(
"Ed25519 signature for record_hash {}",
record.record_hash
),
actual_hash: "missing signature".to_string(),
});
};
let valid = verify_signature(&record.record_hash, sig_hex, vk_bytes)?;
if !valid {
return Ok(ChainStatus::Broken {
at_record_id: record.id.unwrap_or(-1),
expected_hash: format!(
"valid Ed25519 signature for record_hash {}",
record.record_hash
),
actual_hash: "invalid signature".to_string(),
});
}
}
expected_previous = record.record_hash;
}
if record_count == 0 {
Ok(ChainStatus::Empty)
} else {
Ok(ChainStatus::Valid { record_count })
}
})
}
pub fn list(&self, filter: &AttestationFilter) -> Result<Vec<AttestationRecord>> {
let limit = filter.limit.unwrap_or(100) as i64;
let offset = filter.offset.unwrap_or(0) as i64;
let agent_id = filter.agent_id.clone();
let document_name = filter.document_name.clone();
self.storage.with_connection(|conn| {
let mut conditions: Vec<String> = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref aid) = agent_id {
conditions.push(format!("agent_id = ?{}", param_values.len() + 1));
param_values.push(Box::new(aid.clone()));
}
if let Some(ref name) = document_name {
conditions.push(format!(
"document_name LIKE ?{} ESCAPE '\\'",
param_values.len() + 1
));
let escaped = name
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
param_values.push(Box::new(format!("%{}%", escaped)));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let limit_idx = param_values.len() + 1;
let offset_idx = param_values.len() + 2;
param_values.push(Box::new(limit));
param_values.push(Box::new(offset));
let sql = format!(
"SELECT id, document_hash, document_name, document_size, ingested_at,
agent_id, memory_ids, previous_hash, record_hash, signature,
metadata, created_at
FROM attestation_log
{}
ORDER BY id ASC
LIMIT ?{} OFFSET ?{}",
where_clause, limit_idx, offset_idx
);
let mut stmt = conn.prepare(&sql)?;
let refs: Vec<&dyn rusqlite::ToSql> = param_values.iter().map(|b| b.as_ref()).collect();
let rows = stmt.query_map(refs.as_slice(), |row| {
row_to_record(row).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e.to_string(),
)),
)
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(EngramError::Database)
})
}
pub fn compute_record_hash(record: &AttestationRecord) -> String {
let canonical = format!(
"{}|{}|{}|{}|{}|{}|{}",
record.document_hash,
record.document_name,
record.document_size,
record.ingested_at.to_rfc3339(),
record.agent_id.as_deref().unwrap_or(""),
serde_json::to_string(&record.memory_ids).unwrap_or_default(),
record.previous_hash,
);
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
format!("sha256:{}", hex::encode(hasher.finalize()))
}
}
fn hash_bytes(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("sha256:{}", hex::encode(hasher.finalize()))
}
fn sign_record_hash(record_hash: &str, secret_key_bytes: &[u8; 32]) -> Result<String> {
use ed25519_dalek::{Signature, Signer, SigningKey};
let signing_key = SigningKey::from_bytes(secret_key_bytes);
let signature: Signature = signing_key.sign(record_hash.as_bytes());
Ok(hex::encode(signature.to_bytes()))
}
fn verify_signature(
record_hash: &str,
signature_hex: &str,
verifying_key_bytes: &[u8; 32],
) -> Result<bool> {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let key = VerifyingKey::from_bytes(verifying_key_bytes)
.map_err(|e| EngramError::InvalidInput(format!("invalid verifying key: {e}")))?;
let sig_bytes = hex::decode(signature_hex)
.map_err(|e| EngramError::InvalidInput(format!("invalid signature hex: {e}")))?;
let sig_array: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| EngramError::InvalidInput("signature must be 64 bytes".to_string()))?;
let signature = Signature::from_bytes(&sig_array);
Ok(key.verify(record_hash.as_bytes(), &signature).is_ok())
}
fn row_to_record(row: &rusqlite::Row<'_>) -> Result<AttestationRecord> {
let id: i64 = row.get(0)?;
let document_hash: String = row.get(1)?;
let document_name: String = row.get(2)?;
let document_size: i64 = row.get(3)?;
let ingested_at_str: String = row.get(4)?;
let agent_id: Option<String> = row.get(5)?;
let memory_ids_json: String = row.get(6)?;
let previous_hash: String = row.get(7)?;
let record_hash: String = row.get(8)?;
let signature: Option<String> = row.get(9)?;
let metadata_json: String = row.get(10)?;
let created_at_str: Option<String> = row.get(11)?;
let ingested_at = chrono::DateTime::parse_from_rfc3339(&ingested_at_str)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|e| EngramError::Storage(format!("invalid ingested_at: {e}")))?;
let memory_ids: Vec<i64> = serde_json::from_str(&memory_ids_json)
.map_err(|e| EngramError::Storage(format!("invalid memory_ids JSON: {e}")))?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_json)
.map_err(|e| EngramError::Storage(format!("invalid metadata JSON: {e}")))?;
let created_at = created_at_str.and_then(|s| {
chrono::DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.with_timezone(&Utc))
.ok()
});
Ok(AttestationRecord {
id: Some(id),
document_hash,
document_name,
document_size: document_size as usize,
ingested_at,
agent_id,
memory_ids,
previous_hash,
record_hash,
signature,
metadata,
created_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Storage;
fn test_chain() -> AttestationChain {
let storage = Storage::open_in_memory().unwrap();
AttestationChain::new(storage)
}
#[test]
fn test_log_and_verify_document() {
let chain = test_chain();
let content = b"hello, world";
let record = chain
.log_document(content, "hello.txt", Some("agent-1"), &[1, 2, 3], None)
.unwrap();
assert!(record.id.is_some());
assert_eq!(record.document_name, "hello.txt");
assert_eq!(record.agent_id.as_deref(), Some("agent-1"));
assert_eq!(record.memory_ids, vec![1, 2, 3]);
assert_eq!(record.previous_hash, GENESIS_HASH);
assert!(!record.record_hash.is_empty());
let found = chain.verify_document(content).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().document_name, "hello.txt");
}
#[test]
fn test_chain_linkage() {
let chain = test_chain();
let r1 = chain
.log_document(b"doc1", "doc1.txt", None, &[], None)
.unwrap();
let r2 = chain
.log_document(b"doc2", "doc2.txt", None, &[], None)
.unwrap();
assert_eq!(r1.previous_hash, GENESIS_HASH);
assert_eq!(r2.previous_hash, r1.record_hash);
}
#[test]
fn test_verify_chain_valid() {
let chain = test_chain();
chain.log_document(b"a", "a.txt", None, &[], None).unwrap();
chain.log_document(b"b", "b.txt", None, &[], None).unwrap();
match chain.verify_chain(None).unwrap() {
ChainStatus::Valid { record_count } => assert_eq!(record_count, 2),
other => panic!("expected Valid, got {other:?}"),
}
}
#[test]
fn test_verify_chain_empty() {
let chain = test_chain();
assert!(matches!(
chain.verify_chain(None).unwrap(),
ChainStatus::Empty
));
}
#[test]
fn test_chain_stays_linear_under_concurrent_append() {
use std::sync::Arc;
let storage = crate::storage::Storage::open_in_memory().unwrap();
let chain = Arc::new(AttestationChain::new(storage));
let handles: Vec<_> = (0..4)
.map(|i| {
let c = Arc::clone(&chain);
std::thread::spawn(move || {
c.log_document(
format!("content-{i}").as_bytes(),
&format!("doc-{i}.txt"),
None,
&[],
None,
)
.unwrap()
})
})
.collect();
for h in handles {
h.join().unwrap();
}
match chain.verify_chain(None).unwrap() {
ChainStatus::Valid { record_count } => assert_eq!(record_count, 4),
other => panic!("expected Valid, got {other:?}"),
}
}
#[test]
fn test_verify_chain_rejects_tampered_signature() {
use ed25519_dalek::{SigningKey, VerifyingKey};
let chain = test_chain();
let secret = [42u8; 32];
let verifying_key_bytes: [u8; 32] =
VerifyingKey::from(&SigningKey::from_bytes(&secret)).to_bytes();
chain
.log_document(b"data", "doc.txt", None, &[], Some(&secret))
.unwrap();
let zero_signature = "00".repeat(64);
chain
.storage
.with_transaction(|conn| {
conn.execute(
"UPDATE attestation_log SET signature = ?1",
rusqlite::params![zero_signature],
)?;
Ok(())
})
.unwrap();
match chain.verify_chain(Some(&verifying_key_bytes)).unwrap() {
ChainStatus::Broken { .. } => {}
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn test_verify_chain_rejects_stripped_signature_when_key_provided() {
use ed25519_dalek::{SigningKey, VerifyingKey};
let chain = test_chain();
let secret = [42u8; 32];
let verifying_key_bytes: [u8; 32] =
VerifyingKey::from(&SigningKey::from_bytes(&secret)).to_bytes();
chain
.log_document(b"data", "doc.txt", None, &[], Some(&secret))
.unwrap();
chain
.storage
.with_transaction(|conn| {
conn.execute("UPDATE attestation_log SET signature = NULL", [])?;
Ok(())
})
.unwrap();
match chain.verify_chain(Some(&verifying_key_bytes)).unwrap() {
ChainStatus::Broken { .. } => {}
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn test_verify_chain_accepts_valid_signature() {
use ed25519_dalek::{SigningKey, VerifyingKey};
let chain = test_chain();
let secret = [42u8; 32];
let verifying_key_bytes: [u8; 32] =
VerifyingKey::from(&SigningKey::from_bytes(&secret)).to_bytes();
chain
.log_document(b"data", "doc.txt", None, &[], Some(&secret))
.unwrap();
match chain.verify_chain(Some(&verifying_key_bytes)).unwrap() {
ChainStatus::Valid { record_count: 1 } => {}
other => panic!("expected Valid(1), got {other:?}"),
}
}
#[test]
fn test_verify_chain_skips_sig_check_when_no_key_provided() {
let chain = test_chain();
chain
.log_document(b"data", "doc.txt", None, &[], None)
.unwrap();
match chain.verify_chain(None).unwrap() {
ChainStatus::Valid { record_count: 1 } => {}
other => panic!("expected Valid(1), got {other:?}"),
}
}
#[test]
fn test_list_with_filter() {
let chain = test_chain();
chain
.log_document(b"x", "x.txt", Some("agent-A"), &[], None)
.unwrap();
chain
.log_document(b"y", "y.txt", Some("agent-B"), &[], None)
.unwrap();
let filter = AttestationFilter {
agent_id: Some("agent-A".to_string()),
..Default::default()
};
let results = chain.list(&filter).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].document_name, "x.txt");
}
#[test]
fn test_empty_document_name_rejected() {
let chain = test_chain();
let err = chain.log_document(b"data", "", None, &[], None);
assert!(err.is_err());
}
#[test]
fn test_list_document_name_filter_no_wildcard_expansion() {
let chain = test_chain();
chain
.log_document(b"a", "report_final.txt", None, &[], None)
.unwrap();
chain
.log_document(b"b", "reportXfinal.txt", None, &[], None)
.unwrap();
let filter = super::super::types::AttestationFilter {
document_name: Some("report_final".to_string()),
..Default::default()
};
let results = chain.list(&filter).unwrap();
assert_eq!(
results.len(),
1,
"unescaped _ should not act as wildcard; got {results:?}"
);
assert_eq!(results[0].document_name, "report_final.txt");
}
#[test]
fn test_list_document_name_filter_percent_literal() {
let chain = test_chain();
chain
.log_document(b"a", "100% complete.txt", None, &[], None)
.unwrap();
chain
.log_document(b"b", "other.txt", None, &[], None)
.unwrap();
let filter = super::super::types::AttestationFilter {
document_name: Some("100%".to_string()),
..Default::default()
};
let results = chain.list(&filter).unwrap();
assert_eq!(
results.len(),
1,
"percent should be literal, got {results:?}"
);
assert_eq!(results[0].document_name, "100% complete.txt");
}
#[test]
fn test_agent_id_too_long_rejected() {
let chain = test_chain();
let long_id = "a".repeat(257);
let err = chain.log_document(b"data", "doc.txt", Some(&long_id), &[], None);
assert!(err.is_err(), "agent_id > 256 chars should be rejected");
}
#[test]
fn test_agent_id_control_char_rejected() {
let chain = test_chain();
let bad_id = "agent\x00null";
let err = chain.log_document(b"data", "doc.txt", Some(bad_id), &[], None);
assert!(
err.is_err(),
"agent_id with control chars should be rejected"
);
}
#[test]
fn test_agent_id_valid_256_chars_accepted() {
let chain = test_chain();
let id_256 = "a".repeat(256);
let result = chain.log_document(b"data", "doc.txt", Some(&id_256), &[], None);
assert!(result.is_ok(), "agent_id of 256 chars should be accepted");
}
#[test]
fn test_compute_record_hash_deterministic() {
let chain = test_chain();
let r = chain
.log_document(b"stable", "stable.txt", None, &[], None)
.unwrap();
let recomputed = AttestationChain::compute_record_hash(&r);
assert_eq!(r.record_hash, recomputed);
}
#[test]
fn test_chain_integrity_5_docs() {
let chain = test_chain();
let docs = [
(b"alpha" as &[u8], "alpha.txt"),
(b"beta", "beta.txt"),
(b"gamma", "gamma.txt"),
(b"delta", "delta.txt"),
(b"epsilon", "epsilon.txt"),
];
let mut records = Vec::new();
for (content, name) in &docs {
let r = chain
.log_document(content, name, Some("agent-x"), &[], None)
.unwrap();
records.push(r);
}
assert_eq!(records[0].previous_hash, GENESIS_HASH);
for i in 1..5 {
assert_eq!(
records[i].previous_hash,
records[i - 1].record_hash,
"record {i} previous_hash should point to record {} record_hash",
i - 1
);
}
match chain.verify_chain(None).unwrap() {
ChainStatus::Valid { record_count } => assert_eq!(record_count, 5),
other => panic!("expected Valid with 5 records, got {other:?}"),
}
}
#[test]
fn test_document_too_large_rejected() {
let chain = test_chain();
let big = vec![0u8; 100 * 1024 * 1024 + 1];
let err = chain.log_document(&big, "big.bin", None, &[], None);
assert!(err.is_err(), "document > 100 MB should be rejected");
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("too large"),
"error should mention 'too large', got: {msg}"
);
}
#[test]
fn test_document_at_limit_accepted() {
let chain = test_chain();
let at_limit = vec![0u8; 100 * 1024 * 1024];
let result = chain.log_document(&at_limit, "limit.bin", None, &[], None);
assert!(
result.is_ok(),
"document at exactly 100 MB should be accepted"
);
}
#[test]
fn test_too_many_memory_ids_rejected() {
let chain = test_chain();
let ids: Vec<i64> = (0..10_001).collect();
let err = chain.log_document(b"data", "doc.txt", None, &ids, None);
assert!(
err.is_err(),
"more than 10_000 memory_ids should be rejected"
);
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("too many"),
"error should mention 'too many', got: {msg}"
);
}
#[test]
fn test_memory_ids_at_limit_accepted() {
let chain = test_chain();
let ids: Vec<i64> = (0..10_000).collect();
let result = chain.log_document(b"data", "doc.txt", None, &ids, None);
assert!(
result.is_ok(),
"exactly 10_000 memory_ids should be accepted"
);
}
#[test]
fn test_negative_memory_id_rejected() {
let chain = test_chain();
let ids = vec![1i64, 2, -1, 4];
let err = chain.log_document(b"data", "doc.txt", None, &ids, None);
assert!(err.is_err(), "negative memory_id should be rejected");
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("non-negative"),
"error should mention 'non-negative', got: {msg}"
);
}
#[test]
fn test_memory_ids_all_non_negative_accepted() {
let chain = test_chain();
let ids = vec![0i64, 1, 100, 999];
let result = chain.log_document(b"data", "doc.txt", None, &ids, None);
assert!(result.is_ok(), "non-negative memory_ids should be accepted");
}
#[test]
fn test_metadata_too_large_rejected() {
use serde_json::Value;
let chain = test_chain();
let big_string = "x".repeat(66_000);
let _metadata = Value::String(big_string);
let result = chain.log_document(b"data", "doc.txt", None, &[], None);
assert!(result.is_ok());
let record = result.unwrap();
let serialized = serde_json::to_string(&record.metadata).unwrap();
assert!(
serialized.len() <= 65_536,
"default metadata must be within 64 KB"
);
}
#[test]
fn test_document_name_null_byte_rejected() {
let chain = test_chain();
let err = chain.log_document(b"data", "doc\x00.txt", None, &[], None);
assert!(
err.is_err(),
"document_name with null byte must be rejected"
);
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("null"),
"error should mention null bytes, got: {msg}"
);
}
#[test]
fn test_document_name_too_long_rejected() {
let chain = test_chain();
let long_name = "a".repeat(1_001);
let err = chain.log_document(b"data", &long_name, None, &[], None);
assert!(err.is_err(), "document_name > 1000 chars must be rejected");
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("too long") || msg.contains("1000"),
"error should mention length limit, got: {msg}"
);
}
#[test]
fn test_document_name_exactly_1000_chars_accepted() {
let chain = test_chain();
let name_1000 = "a".repeat(1_000);
let result = chain.log_document(b"data", &name_1000, None, &[], None);
assert!(
result.is_ok(),
"document_name of exactly 1000 chars should be accepted"
);
}
#[test]
fn test_corrupt_metadata_json_returns_error() {
let storage = Storage::open_in_memory().unwrap();
let chain = AttestationChain::new(storage.clone());
chain
.log_document(b"data", "doc.txt", None, &[], None)
.unwrap();
storage
.with_transaction(|conn| {
conn.execute("UPDATE attestation_log SET metadata = '{broken'", [])?;
Ok(())
})
.unwrap();
let result = chain.verify_chain(None);
assert!(
result.is_err(),
"corrupt metadata JSON must propagate an error, got: {result:?}"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("invalid metadata JSON") || msg.contains("metadata"),
"error should mention metadata, got: {msg}"
);
}
#[test]
fn test_chain_tamper_detection() {
let storage = Storage::open_in_memory().unwrap();
let chain = AttestationChain::new(storage.clone());
chain
.log_document(b"first", "first.txt", None, &[], None)
.unwrap();
let r2 = chain
.log_document(b"second", "second.txt", None, &[], None)
.unwrap();
assert!(matches!(
chain.verify_chain(None).unwrap(),
ChainStatus::Valid { .. }
));
let r2_id = r2.id.unwrap();
storage
.with_transaction(|conn| {
conn.execute(
"UPDATE attestation_log SET record_hash = 'sha256:0000tampered' WHERE id = ?1",
rusqlite::params![r2_id],
)?;
Ok(())
})
.expect("tamper record");
match chain.verify_chain(None).unwrap() {
ChainStatus::Broken { at_record_id, .. } => {
assert!(at_record_id > 0);
}
other => panic!("expected Broken chain after tamper, got {other:?}"),
}
}
}