1use async_trait::async_trait;
6use chrono::Utc;
7use std::path::PathBuf;
8use tokio::fs::{self, OpenOptions};
9use tokio::io::AsyncWriteExt;
10use vex_core::Hash;
11
12use crate::backend::{AnchorBackend, AnchorMetadata, AnchorReceipt};
13use crate::error::AnchorError;
14
15#[derive(Debug, Clone)]
23pub struct FileAnchor {
24 path: PathBuf,
25}
26
27impl FileAnchor {
28 pub fn new(path: impl Into<PathBuf>) -> Self {
34 Self { path: path.into() }
35 }
36
37 pub fn with_base_dir(
46 path: impl Into<PathBuf>,
47 base_dir: impl Into<PathBuf>,
48 ) -> Result<Self, AnchorError> {
49 let path = path.into();
50 let base_dir = base_dir.into();
51
52 let path_str = path.to_string_lossy();
54 if path_str.contains("..") {
55 return Err(AnchorError::BackendUnavailable(
56 "Path traversal detected: '..' not allowed in anchor path".to_string(),
57 ));
58 }
59
60 let resolved = if path.is_absolute() {
63 path.clone()
64 } else {
65 base_dir.join(&path)
66 };
67
68 let base_canonical = base_dir.canonicalize().unwrap_or(base_dir);
71 let resolved_parent = resolved
72 .parent()
73 .map(|p| p.to_path_buf())
74 .unwrap_or(resolved.clone());
75
76 if !resolved_parent.starts_with(&base_canonical) && resolved_parent != base_canonical {
77 let parent_str = resolved_parent.to_string_lossy();
79 if !parent_str.starts_with(base_canonical.to_string_lossy().as_ref()) {
80 return Err(AnchorError::BackendUnavailable(format!(
81 "Path '{}' is outside allowed directory '{}'",
82 resolved.display(),
83 base_canonical.display()
84 )));
85 }
86 }
87
88 Ok(Self { path: resolved })
89 }
90
91 pub fn path(&self) -> &PathBuf {
93 &self.path
94 }
95}
96
97#[async_trait]
98impl AnchorBackend for FileAnchor {
99 async fn anchor(
100 &self,
101 root: &Hash,
102 metadata: AnchorMetadata,
103 ) -> Result<AnchorReceipt, AnchorError> {
104 let anchor_id = uuid::Uuid::new_v4().to_string();
105 let receipt = AnchorReceipt {
106 backend: self.name().to_string(),
107 root_hash: root.to_hex(),
108 anchor_id: anchor_id.clone(),
109 anchored_at: Utc::now(),
110 proof: None,
111 metadata,
112 };
113
114 let mut file = OpenOptions::new()
116 .create(true)
117 .append(true)
118 .open(&self.path)
119 .await?;
120
121 let mut json = serde_json::to_string(&receipt)?;
122 json.push('\n');
123 file.write_all(json.as_bytes()).await?;
124 file.flush().await?;
125
126 Ok(receipt)
127 }
128
129 async fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, AnchorError> {
130 use subtle::ConstantTimeEq;
131
132 if !self.path.exists() {
133 return Ok(false);
134 }
135
136 let content = fs::read_to_string(&self.path).await?;
137
138 for line in content.lines() {
139 if line.trim().is_empty() {
140 continue;
141 }
142
143 let parsed: Result<AnchorReceipt, _> = serde_json::from_str(line);
144 if let Ok(stored) = parsed {
145 let id_match = stored
147 .anchor_id
148 .as_bytes()
149 .ct_eq(receipt.anchor_id.as_bytes());
150 let hash_match = stored
151 .root_hash
152 .as_bytes()
153 .ct_eq(receipt.root_hash.as_bytes());
154
155 if id_match.into() && hash_match.into() {
156 return Ok(true);
157 }
158 }
159 }
160
161 Ok(false)
162 }
163
164 fn name(&self) -> &str {
165 "file"
166 }
167
168 async fn is_healthy(&self) -> bool {
169 if let Some(parent) = self.path.parent() {
171 if !parent.exists() {
172 return fs::create_dir_all(parent).await.is_ok();
173 }
174 }
175 true
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use tempfile::tempdir;
183
184 #[tokio::test]
185 async fn test_file_anchor_roundtrip() {
186 let dir = tempdir().unwrap();
187 let path = dir.path().join("anchors.jsonl");
188 let anchor = FileAnchor::new(&path);
189
190 let root = Hash::digest(b"test_merkle_root");
191 let metadata = AnchorMetadata::new("tenant-1", 100);
192
193 let receipt = anchor.anchor(&root, metadata).await.unwrap();
195 assert_eq!(receipt.backend, "file");
196 assert_eq!(receipt.root_hash, root.to_hex());
197
198 let valid = anchor.verify(&receipt).await.unwrap();
200 assert!(valid, "Receipt should verify");
201
202 let mut fake = receipt.clone();
204 fake.anchor_id = "fake-id".to_string();
205 let invalid = anchor.verify(&fake).await.unwrap();
206 assert!(!invalid, "Fake receipt should not verify");
207 }
208
209 #[tokio::test]
210 async fn test_file_anchor_multiple() {
211 let dir = tempdir().unwrap();
212 let path = dir.path().join("anchors.jsonl");
213 let anchor = FileAnchor::new(&path);
214
215 let mut receipts = Vec::new();
216 for i in 0..5 {
217 let root = Hash::digest(format!("root_{}", i).as_bytes());
218 let metadata = AnchorMetadata::new("tenant-1", i as u64);
219 let receipt = anchor.anchor(&root, metadata).await.unwrap();
220 receipts.push(receipt);
221 }
222
223 for receipt in &receipts {
225 assert!(anchor.verify(receipt).await.unwrap());
226 }
227
228 let content = fs::read_to_string(&path).await.unwrap();
230 assert_eq!(content.lines().count(), 5);
231 }
232}