tirea_store_adapters/
file_utils.rs

1use std::path::Path;
2use tokio::io::AsyncWriteExt;
3
4/// Validate an ID for filesystem safety.
5///
6/// Rejects empty strings, path separators, `..`, null bytes, and control characters.
7pub fn validate_fs_id(id: &str, label: &str) -> Result<(), String> {
8    if id.trim().is_empty() {
9        return Err(format!("{label} cannot be empty"));
10    }
11    if id.contains('/')
12        || id.contains('\\')
13        || id.contains("..")
14        || id.contains('\0')
15        || id.chars().any(|c| c.is_control())
16    {
17        return Err(format!("{label} contains invalid characters: {id:?}"));
18    }
19    Ok(())
20}
21
22/// Atomic JSON write: write to tmp file, fsync, then rename into place.
23///
24/// If the target already exists and rename fails with `AlreadyExists`,
25/// falls back to remove-then-rename (for non-POSIX platforms).
26pub async fn atomic_json_write(
27    base_dir: &Path,
28    filename: &str,
29    content: &str,
30) -> Result<(), std::io::Error> {
31    if !base_dir.exists() {
32        tokio::fs::create_dir_all(base_dir).await?;
33    }
34
35    let target = base_dir.join(filename);
36    let tmp_path = base_dir.join(format!(
37        ".{}.{}.tmp",
38        filename.trim_end_matches(".json"),
39        uuid::Uuid::new_v4().simple()
40    ));
41
42    let write_result = async {
43        let mut file = tokio::fs::File::create(&tmp_path).await?;
44        file.write_all(content.as_bytes()).await?;
45        file.flush().await?;
46        file.sync_all().await?;
47        drop(file);
48        match tokio::fs::rename(&tmp_path, &target).await {
49            Ok(()) => {}
50            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
51                tokio::fs::remove_file(&target).await?;
52                tokio::fs::rename(&tmp_path, &target).await?;
53            }
54            Err(e) => return Err(e),
55        }
56        Ok::<(), std::io::Error>(())
57    }
58    .await;
59
60    if let Err(e) = write_result {
61        let _ = tokio::fs::remove_file(&tmp_path).await;
62        return Err(e);
63    }
64    Ok(())
65}
66
67/// Scan a directory for `.json` files and return their file stems.
68pub async fn scan_json_stems(path: &Path) -> Result<Vec<String>, std::io::Error> {
69    if !path.exists() {
70        return Ok(Vec::new());
71    }
72    let mut entries = tokio::fs::read_dir(path).await?;
73    let mut stems = Vec::new();
74    while let Some(entry) = entries.next_entry().await? {
75        let p = entry.path();
76        if p.extension().is_some_and(|ext| ext == "json") {
77            if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
78                stems.push(stem.to_string());
79            }
80        }
81    }
82    Ok(stems)
83}