1use std::fs;
25use std::io;
26use std::io::Write as _;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use serde::{de::DeserializeOwned, Serialize};
31use tempfile::NamedTempFile;
32
33use crate::backend::StorageBackend;
34use crate::codec::{Codec, JsonCodec};
35use crate::error::StorageError;
36use crate::memory::{
37 append_log_storage, kv_storage, snapshot_storage, AppendLogStorage, AppendLogStorageOptions,
38 KvStorage, KvStorageOptions, SnapshotStorage, SnapshotStorageOptions,
39};
40
41const FILE_SUFFIX: &str = ".bin";
44
45const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
49
50#[derive(Debug)]
77pub struct FileBackend {
78 dir: PathBuf,
79 name: String,
80 include_hidden: bool,
81}
82
83impl FileBackend {
84 #[must_use]
87 pub fn new(dir: impl AsRef<Path>) -> Self {
88 let dir = dir.as_ref().to_path_buf();
89 let name = format!("file:{}", dir.display());
90 Self {
91 dir,
92 name,
93 include_hidden: false,
94 }
95 }
96
97 #[must_use]
108 pub fn with_include_hidden(mut self, include: bool) -> Self {
109 self.include_hidden = include;
110 self
111 }
112
113 #[must_use]
115 pub fn dir(&self) -> &Path {
116 &self.dir
117 }
118
119 #[must_use]
121 pub fn include_hidden(&self) -> bool {
122 self.include_hidden
123 }
124
125 fn path_for(&self, key: &str) -> PathBuf {
127 let mut filename = encode_key_to_filename(key);
128 filename.push_str(FILE_SUFFIX);
129 self.dir.join(filename)
130 }
131}
132
133#[must_use]
138pub fn file_backend(dir: impl AsRef<Path>) -> Arc<FileBackend> {
139 Arc::new(FileBackend::new(dir))
140}
141
142impl StorageBackend for FileBackend {
143 fn name(&self) -> &str {
144 &self.name
145 }
146
147 fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StorageError> {
148 match fs::read(self.path_for(key)) {
149 Ok(bytes) => Ok(Some(bytes)),
150 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
151 Err(e) => Err(io_error("read", &self.dir, e)),
152 }
153 }
154
155 fn write(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
156 fs::create_dir_all(&self.dir).map_err(|e| io_error("mkdir", &self.dir, e))?;
157 let target = self.path_for(key);
158 let mut tmp =
159 NamedTempFile::new_in(&self.dir).map_err(|e| io_error("tempfile", &self.dir, e))?;
160 tmp.write_all(bytes)
161 .map_err(|e| io_error("write tmp", &self.dir, e))?;
162 tmp.persist(&target)
163 .map_err(|e| io_error("rename", &self.dir, e.error))?;
164 Ok(())
165 }
166
167 fn delete(&self, key: &str) -> Result<(), StorageError> {
168 match fs::remove_file(self.path_for(key)) {
169 Ok(()) => Ok(()),
170 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
171 Err(e) => Err(io_error("delete", &self.dir, e)),
172 }
173 }
174
175 fn list(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
176 let entries = match fs::read_dir(&self.dir) {
177 Ok(e) => e,
178 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
179 Err(e) => return Err(io_error("list", &self.dir, e)),
180 };
181 let mut keys = Vec::new();
182 for entry in entries {
183 let entry = entry.map_err(|e| io_error("list-entry", &self.dir, e))?;
184 let raw = entry.file_name();
185 let Some(name) = raw.to_str() else { continue };
186 if !self.include_hidden && name.starts_with('.') {
187 continue;
188 }
189 let Some(key) = decode_filename_to_key(name) else {
190 continue;
191 };
192 if !prefix.is_empty() && !key.starts_with(prefix) {
193 continue;
194 }
195 keys.push(key);
196 }
197 keys.sort();
198 Ok(keys)
199 }
200}
201
202fn io_error(op: &str, dir: &Path, source: io::Error) -> StorageError {
203 StorageError::BackendError {
204 message: format!("file backend {op} failed at {}: {source}", dir.display()),
205 source: Some(Box::new(source)),
206 }
207}
208
209fn encode_key_to_filename(key: &str) -> String {
215 let mut out = String::with_capacity(key.len());
216 let mut buf = [0u8; 4];
217 for ch in key.chars() {
218 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
219 out.push(ch);
220 continue;
221 }
222 for &byte in ch.encode_utf8(&mut buf).as_bytes() {
223 out.push('%');
224 out.push(HEX_LOWER[(byte >> 4) as usize] as char);
225 out.push(HEX_LOWER[(byte & 0x0F) as usize] as char);
226 }
227 }
228 out
229}
230
231fn decode_filename_to_key(filename: &str) -> Option<String> {
244 let stem = filename.strip_suffix(FILE_SUFFIX)?;
245 let chars: Vec<char> = stem.chars().collect();
246 let mut bytes: Vec<u8> = Vec::with_capacity(chars.len());
247 let mut i = 0;
248 while i < chars.len() {
249 let ch = chars[i];
250 if ch == '%' && i + 2 < chars.len() {
251 if let (Some(hi), Some(lo)) = (nibble(chars[i + 1]), nibble(chars[i + 2])) {
252 bytes.push((hi << 4) | lo);
253 i += 3;
254 continue;
255 }
256 }
257 if !ch.is_ascii() {
258 return None;
259 }
260 bytes.push(ch as u8);
261 i += 1;
262 }
263 String::from_utf8(bytes).ok()
264}
265
266fn nibble(c: char) -> Option<u8> {
267 c.to_digit(16).and_then(|d| u8::try_from(d).ok())
268}
269
270#[must_use]
275pub fn file_snapshot<T, C>(
276 dir: impl AsRef<Path>,
277 opts: SnapshotStorageOptions<T, C>,
278) -> SnapshotStorage<FileBackend, T, C>
279where
280 T: Send + Sync + 'static,
281 C: Codec<T>,
282{
283 snapshot_storage(Arc::new(FileBackend::new(dir)), opts)
284}
285
286#[must_use]
289pub fn file_snapshot_default<T>(dir: impl AsRef<Path>) -> SnapshotStorage<FileBackend, T, JsonCodec>
290where
291 T: Serialize + DeserializeOwned + Send + Sync + 'static,
292{
293 file_snapshot(dir, SnapshotStorageOptions::default())
294}
295
296#[must_use]
298pub fn file_append_log<T, C>(
299 dir: impl AsRef<Path>,
300 opts: AppendLogStorageOptions<T, C>,
301) -> AppendLogStorage<FileBackend, T, C>
302where
303 T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
304 C: Codec<Vec<T>>,
305{
306 append_log_storage(Arc::new(FileBackend::new(dir)), opts)
307}
308
309#[must_use]
312pub fn file_append_log_default<T>(
313 dir: impl AsRef<Path>,
314) -> AppendLogStorage<FileBackend, T, JsonCodec>
315where
316 T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
317{
318 file_append_log(dir, AppendLogStorageOptions::default())
319}
320
321#[must_use]
323pub fn file_kv<T, C>(
324 dir: impl AsRef<Path>,
325 opts: KvStorageOptions<T, C>,
326) -> KvStorage<FileBackend, T, C>
327where
328 T: Send + Sync + 'static,
329 C: Codec<T>,
330{
331 kv_storage(Arc::new(FileBackend::new(dir)), opts)
332}
333
334#[must_use]
337pub fn file_kv_default<T>(dir: impl AsRef<Path>) -> KvStorage<FileBackend, T, JsonCodec>
338where
339 T: Serialize + DeserializeOwned + Send + Sync + 'static,
340{
341 file_kv(dir, KvStorageOptions::default())
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn encode_alphanumeric_passthrough() {
350 assert_eq!(encode_key_to_filename("abcXYZ-_09"), "abcXYZ-_09");
351 }
352
353 #[test]
354 fn encode_special_chars_percent_escape() {
355 assert_eq!(
356 encode_key_to_filename("app/with:slashes"),
357 "app%2fwith%3aslashes"
358 );
359 }
360
361 #[test]
362 fn encode_non_ascii_two_byte_utf8() {
363 assert_eq!(encode_key_to_filename("café"), "caf%c3%a9");
365 }
366
367 #[test]
368 fn encode_non_ascii_three_byte_utf8() {
369 assert_eq!(encode_key_to_filename("€100"), "%e2%82%ac100");
371 }
372
373 #[test]
374 fn encode_emoji_four_byte_utf8() {
375 assert_eq!(encode_key_to_filename("👋"), "%f0%9f%91%8b");
377 }
378
379 #[test]
380 fn encode_empty_key() {
381 assert_eq!(encode_key_to_filename(""), "");
382 }
383
384 #[test]
385 fn decode_round_trip_covers_canonical_set() {
386 for key in [
387 "simple",
388 "app/with:slashes",
389 "café",
390 "€100",
391 "👋 hello",
392 "a-b_c",
393 "",
394 ] {
395 let filename = format!("{}.bin", encode_key_to_filename(key));
396 assert_eq!(
397 decode_filename_to_key(&filename).as_deref(),
398 Some(key),
399 "round-trip failed for {key:?}",
400 );
401 }
402 }
403
404 #[test]
405 fn decode_rejects_non_bin_suffix() {
406 assert!(decode_filename_to_key("foo.txt").is_none());
407 assert!(decode_filename_to_key("foo").is_none());
408 assert!(decode_filename_to_key(".bin").is_some()); }
410
411 #[test]
412 fn decode_truncated_percent_escape_treated_literally() {
413 assert_eq!(
416 decode_filename_to_key("abc%5.bin").as_deref(),
417 Some("abc%5")
418 );
419 }
420
421 #[test]
422 fn decode_invalid_hex_treated_literally() {
423 assert_eq!(
425 decode_filename_to_key("abc%5z.bin").as_deref(),
426 Some("abc%5z")
427 );
428 }
429
430 #[test]
431 fn decode_uppercase_hex_accepted() {
432 assert_eq!(
435 decode_filename_to_key("caf%C3%A9.bin").as_deref(),
436 Some("café")
437 );
438 }
439
440 #[test]
441 fn decode_rejects_non_ascii_outside_escapes() {
442 assert!(decode_filename_to_key("café.bin").is_none());
445 }
446
447 #[test]
448 fn nibble_validates_hex_set() {
449 for c in ['0', '5', '9', 'a', 'f', 'A', 'F'] {
450 assert!(nibble(c).is_some(), "{c} should be a hex digit");
451 }
452 for c in ['g', 'G', '/', '@', '\u{00e9}'] {
453 assert!(nibble(c).is_none(), "{c} should not be a hex digit");
454 }
455 }
456}