tirea_store_adapters/
file_utils.rs1use std::path::Path;
2use tokio::io::AsyncWriteExt;
3
4pub 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
22pub 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
67pub 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}