Skip to main content

auths_transparency/
fs_store.rs

1use std::path::PathBuf;
2
3use crate::error::TransparencyError;
4use crate::store::TileStore;
5use crate::writer::LogSigningKey;
6
7/// PKCS#8 signing-key file kept inside a local log directory.
8const LOG_KEY_FILE: &str = "log.key";
9
10/// Filesystem-backed tile store.
11///
12/// Stores tiles and checkpoints as plain files under a base directory.
13/// Full tiles (paths without `.p/`) are write-once: subsequent writes
14/// are silently skipped. Partial tiles (paths containing `.p/`) and
15/// the checkpoint file are always overwritable.
16///
17/// Args:
18/// * `base_path` — Root directory for all tile and checkpoint files.
19///
20/// Usage:
21/// ```ignore
22/// let store = FsTileStore::new("/home/user/.auths/tlog".into());
23/// store.write_tile("tile/0/000", &data).await?;
24/// ```
25pub struct FsTileStore {
26    base_path: PathBuf,
27}
28
29impl FsTileStore {
30    /// Creates a new filesystem tile store rooted at the given path.
31    ///
32    /// Args:
33    /// * `base_path` — Directory where tiles and checkpoints are stored.
34    ///
35    /// Usage:
36    /// ```ignore
37    /// let store = FsTileStore::new(PathBuf::from("/tmp/tlog"));
38    /// ```
39    pub fn new(base_path: PathBuf) -> Self {
40        Self { base_path }
41    }
42
43    /// Create the store's base directory (and any missing parents).
44    ///
45    /// Args:
46    /// * none beyond `self`.
47    ///
48    /// Usage:
49    /// ```ignore
50    /// store.ensure_base_dir()?;
51    /// ```
52    pub fn ensure_base_dir(&self) -> Result<(), TransparencyError> {
53        std::fs::create_dir_all(&self.base_path)
54            .map_err(|e| TransparencyError::StoreError(e.to_string()))
55    }
56
57    /// Load the log's signing key from `<base>/log.key`, minting and
58    /// persisting a fresh one (mode `0600` on Unix) on first use when `create`
59    /// is set.
60    ///
61    /// Returns `Ok(None)` when the key is absent and `create` is false, so a
62    /// caller can distinguish "no log here yet" from an I/O failure without
63    /// itself touching the filesystem.
64    ///
65    /// Args:
66    /// * `create` — Generate and persist a key if none exists yet.
67    ///
68    /// Usage:
69    /// ```ignore
70    /// let key = store.load_or_create_key(true)?.expect("created on first use");
71    /// ```
72    pub fn load_or_create_key(
73        &self,
74        create: bool,
75    ) -> Result<Option<LogSigningKey>, TransparencyError> {
76        let path = self.base_path.join(LOG_KEY_FILE);
77        match std::fs::read(&path) {
78            Ok(der) => LogSigningKey::from_pkcs8_der(&der).map(Some),
79            Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => {
80                let key = LogSigningKey::generate()?;
81                let der = key.to_pkcs8_der()?;
82                std::fs::write(&path, &der)
83                    .map_err(|e| TransparencyError::StoreError(e.to_string()))?;
84                #[cfg(unix)]
85                {
86                    use std::os::unix::fs::PermissionsExt;
87                    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
88                        .map_err(|e| TransparencyError::StoreError(e.to_string()))?;
89                }
90                Ok(Some(key))
91            }
92            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
93            Err(e) => Err(TransparencyError::StoreError(e.to_string())),
94        }
95    }
96}
97
98fn is_partial_tile(path: &str) -> bool {
99    path.contains(".p/")
100}
101
102#[async_trait::async_trait]
103impl TileStore for FsTileStore {
104    async fn read_tile(&self, path: &str) -> Result<Vec<u8>, TransparencyError> {
105        let full_path = self.base_path.join(path);
106        tokio::fs::read(&full_path)
107            .await
108            .map_err(|e| TransparencyError::StoreError(e.to_string()))
109    }
110
111    async fn write_tile(&self, path: &str, data: &[u8]) -> Result<(), TransparencyError> {
112        let full_path = self.base_path.join(path);
113
114        if !is_partial_tile(path) && full_path.exists() {
115            return Ok(());
116        }
117
118        if let Some(parent) = full_path.parent() {
119            tokio::fs::create_dir_all(parent)
120                .await
121                .map_err(|e| TransparencyError::StoreError(e.to_string()))?;
122        }
123
124        tokio::fs::write(&full_path, data)
125            .await
126            .map_err(|e| TransparencyError::StoreError(e.to_string()))
127    }
128
129    async fn read_checkpoint(&self) -> Result<Option<Vec<u8>>, TransparencyError> {
130        let path = self.base_path.join("checkpoint");
131        match tokio::fs::read(&path).await {
132            Ok(data) => Ok(Some(data)),
133            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
134            Err(e) => Err(TransparencyError::StoreError(e.to_string())),
135        }
136    }
137
138    async fn write_checkpoint(&self, data: &[u8]) -> Result<(), TransparencyError> {
139        let path = self.base_path.join("checkpoint");
140
141        if let Some(parent) = path.parent() {
142            tokio::fs::create_dir_all(parent)
143                .await
144                .map_err(|e| TransparencyError::StoreError(e.to_string()))?;
145        }
146
147        tokio::fs::write(&path, data)
148            .await
149            .map_err(|e| TransparencyError::StoreError(e.to_string()))
150    }
151}
152
153#[cfg(test)]
154#[allow(clippy::unwrap_used, clippy::expect_used)]
155mod tests {
156    use super::*;
157
158    #[tokio::test]
159    async fn tile_write_read_roundtrip() {
160        let dir = tempfile::tempdir().unwrap();
161        let store = FsTileStore::new(dir.path().to_path_buf());
162
163        let data = b"leaf data";
164        store.write_tile("tile/0/000", data).await.unwrap();
165
166        let read_back = store.read_tile("tile/0/000").await.unwrap();
167        assert_eq!(read_back, data);
168    }
169
170    #[tokio::test]
171    async fn tile_creates_nested_directories() {
172        let dir = tempfile::tempdir().unwrap();
173        let store = FsTileStore::new(dir.path().to_path_buf());
174
175        store.write_tile("tile/2/001/002", b"deep").await.unwrap();
176
177        let on_disk = dir.path().join("tile/2/001/002");
178        assert!(on_disk.exists());
179    }
180
181    #[tokio::test]
182    async fn full_tile_is_immutable() {
183        let dir = tempfile::tempdir().unwrap();
184        let store = FsTileStore::new(dir.path().to_path_buf());
185
186        store.write_tile("tile/0/000", b"first").await.unwrap();
187        store.write_tile("tile/0/000", b"second").await.unwrap();
188
189        let data = store.read_tile("tile/0/000").await.unwrap();
190        assert_eq!(data, b"first");
191    }
192
193    #[tokio::test]
194    async fn partial_tile_is_overwritable() {
195        let dir = tempfile::tempdir().unwrap();
196        let store = FsTileStore::new(dir.path().to_path_buf());
197
198        store
199            .write_tile("tile/0/000.p/5", b"partial-v1")
200            .await
201            .unwrap();
202        store
203            .write_tile("tile/0/000.p/5", b"partial-v2")
204            .await
205            .unwrap();
206
207        let data = store.read_tile("tile/0/000.p/5").await.unwrap();
208        assert_eq!(data, b"partial-v2");
209    }
210
211    #[tokio::test]
212    async fn checkpoint_roundtrip() {
213        let dir = tempfile::tempdir().unwrap();
214        let store = FsTileStore::new(dir.path().to_path_buf());
215
216        let result = store.read_checkpoint().await.unwrap();
217        assert!(result.is_none());
218
219        store.write_checkpoint(b"cp-v1").await.unwrap();
220        let data = store.read_checkpoint().await.unwrap();
221        assert_eq!(data, Some(b"cp-v1".to_vec()));
222
223        store.write_checkpoint(b"cp-v2").await.unwrap();
224        let data = store.read_checkpoint().await.unwrap();
225        assert_eq!(data, Some(b"cp-v2".to_vec()));
226    }
227
228    #[tokio::test]
229    async fn read_nonexistent_tile_returns_error() {
230        let dir = tempfile::tempdir().unwrap();
231        let store = FsTileStore::new(dir.path().to_path_buf());
232
233        let result = store.read_tile("tile/0/999").await;
234        assert!(result.is_err());
235    }
236}