use async_trait::async_trait;
use chrono::Utc;
use std::path::PathBuf;
use tokio::fs::{self, OpenOptions};
use tokio::io::AsyncWriteExt;
use vex_core::Hash;
use crate::backend::{AnchorBackend, AnchorMetadata, AnchorReceipt};
use crate::error::AnchorError;
#[derive(Debug, Clone)]
pub struct FileAnchor {
path: PathBuf,
}
impl FileAnchor {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn with_base_dir(
path: impl Into<PathBuf>,
base_dir: impl Into<PathBuf>,
) -> Result<Self, AnchorError> {
let path = path.into();
let base_dir = base_dir.into();
let path_str = path.to_string_lossy();
if path_str.contains("..") {
return Err(AnchorError::BackendUnavailable(
"Path traversal detected: '..' not allowed in anchor path".to_string(),
));
}
let resolved = if path.is_absolute() {
path.clone()
} else {
base_dir.join(&path)
};
let base_canonical = base_dir.canonicalize().unwrap_or(base_dir);
let resolved_parent = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or(resolved.clone());
if !resolved_parent.starts_with(&base_canonical) && resolved_parent != base_canonical {
let parent_str = resolved_parent.to_string_lossy();
if !parent_str.starts_with(base_canonical.to_string_lossy().as_ref()) {
return Err(AnchorError::BackendUnavailable(format!(
"Path '{}' is outside allowed directory '{}'",
resolved.display(),
base_canonical.display()
)));
}
}
Ok(Self { path: resolved })
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
#[async_trait]
impl AnchorBackend for FileAnchor {
async fn anchor(
&self,
root: &Hash,
metadata: AnchorMetadata,
) -> Result<AnchorReceipt, AnchorError> {
let anchor_id = uuid::Uuid::new_v4().to_string();
let receipt = AnchorReceipt {
backend: self.name().to_string(),
root_hash: root.to_hex(),
anchor_id: anchor_id.clone(),
anchored_at: Utc::now(),
proof: None,
metadata,
};
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await?;
let mut json = serde_json::to_string(&receipt)?;
json.push('\n');
file.write_all(json.as_bytes()).await?;
file.flush().await?;
Ok(receipt)
}
async fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, AnchorError> {
use subtle::ConstantTimeEq;
if !self.path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&self.path).await?;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let parsed: Result<AnchorReceipt, _> = serde_json::from_str(line);
if let Ok(stored) = parsed {
let id_match = stored
.anchor_id
.as_bytes()
.ct_eq(receipt.anchor_id.as_bytes());
let hash_match = stored
.root_hash
.as_bytes()
.ct_eq(receipt.root_hash.as_bytes());
if id_match.into() && hash_match.into() {
return Ok(true);
}
}
}
Ok(false)
}
fn name(&self) -> &str {
"file"
}
async fn is_healthy(&self) -> bool {
if let Some(parent) = self.path.parent() {
if !parent.exists() {
return fs::create_dir_all(parent).await.is_ok();
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_file_anchor_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("anchors.jsonl");
let anchor = FileAnchor::new(&path);
let root = Hash::digest(b"test_merkle_root");
let metadata = AnchorMetadata::new("tenant-1", 100);
let receipt = anchor.anchor(&root, metadata).await.unwrap();
assert_eq!(receipt.backend, "file");
assert_eq!(receipt.root_hash, root.to_hex());
let valid = anchor.verify(&receipt).await.unwrap();
assert!(valid, "Receipt should verify");
let mut fake = receipt.clone();
fake.anchor_id = "fake-id".to_string();
let invalid = anchor.verify(&fake).await.unwrap();
assert!(!invalid, "Fake receipt should not verify");
}
#[tokio::test]
async fn test_file_anchor_multiple() {
let dir = tempdir().unwrap();
let path = dir.path().join("anchors.jsonl");
let anchor = FileAnchor::new(&path);
let mut receipts = Vec::new();
for i in 0..5 {
let root = Hash::digest(format!("root_{}", i).as_bytes());
let metadata = AnchorMetadata::new("tenant-1", i as u64);
let receipt = anchor.anchor(&root, metadata).await.unwrap();
receipts.push(receipt);
}
for receipt in &receipts {
assert!(anchor.verify(receipt).await.unwrap());
}
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content.lines().count(), 5);
}
}