vex_anchor/
file.rs

1//! File-based anchoring backend
2//!
3//! Appends anchor receipts to a local JSON file for development and testing.
4
5use 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/// File-based anchor backend
16///
17/// Stores anchor receipts in a local JSON Lines file (one receipt per line).
18/// Suitable for development, testing, and single-node deployments.
19///
20/// # Security
21/// Use `try_new()` or `with_base_dir()` for production to prevent path traversal.
22#[derive(Debug, Clone)]
23pub struct FileAnchor {
24    path: PathBuf,
25}
26
27impl FileAnchor {
28    /// Create a new file anchor (no path validation)
29    ///
30    /// # Warning
31    /// This constructor does not validate the path. For production use,
32    /// prefer `try_new()` or `with_base_dir()` to prevent path traversal.
33    pub fn new(path: impl Into<PathBuf>) -> Self {
34        Self { path: path.into() }
35    }
36
37    /// Create a file anchor with path validation (HIGH-1 fix)
38    ///
39    /// Validates that the path:
40    /// - Does not contain path traversal sequences (`..`)
41    /// - Is within the specified base directory
42    ///
43    /// # Errors
44    /// Returns `AnchorError::BackendUnavailable` if path validation fails.
45    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        // Check for path traversal in the filename/path components
53        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        // Ensure the path is under the base directory
61        // Use the raw path if canonicalize fails (file may not exist yet)
62        let resolved = if path.is_absolute() {
63            path.clone()
64        } else {
65            base_dir.join(&path)
66        };
67
68        // Check that resolved path starts with base_dir
69        // This prevents ../../../etc/passwd style attacks
70        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            // For new files, check if the parent would be valid
78            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    /// Get the path to the anchor file
92    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        // Append to file (JSON Lines format)
115        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                // Use constant-time comparison to prevent timing attacks (LOW-1 fix)
146                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        // Check if we can write to the directory
170        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        // Anchor
194        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        // Verify
199        let valid = anchor.verify(&receipt).await.unwrap();
200        assert!(valid, "Receipt should verify");
201
202        // Invalid receipt should not verify
203        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        // All should verify
224        for receipt in &receipts {
225            assert!(anchor.verify(receipt).await.unwrap());
226        }
227
228        // Check file has 5 lines
229        let content = fs::read_to_string(&path).await.unwrap();
230        assert_eq!(content.lines().count(), 5);
231    }
232}