io.rs (5322B)
1 use anyhow::{Context, Result}; 2 use std::fs::{self, File}; 3 use std::io::Write; 4 use std::path::{Path, PathBuf}; 5 use thiserror::Error; 6 use tracing::debug; 7 8 use crate::utils::crypto::compute_hash; 9 10 #[derive(Debug, Error)] 11 pub enum PathsError { 12 #[error("Invalid path segment at index {index}: `{segment}`")] 13 InvalidSegment { index: usize, segment: String }, 14 } 15 16 pub fn paths_join<I, S>(segments: I) -> Result<PathBuf, PathsError> 17 where 18 I: IntoIterator<Item = S>, 19 S: AsRef<Path>, 20 { 21 let mut path = PathBuf::new(); 22 for (i, segment) in segments.into_iter().enumerate() { 23 let seg = segment.as_ref(); 24 if seg.as_os_str().is_empty() { 25 return Err(PathsError::InvalidSegment { 26 index: i, 27 segment: seg.display().to_string(), 28 }); 29 } 30 path.push(seg); 31 } 32 Ok(path) 33 } 34 35 pub fn safe_path_segment(segment: &str) -> Option<String> { 36 let mut components = Path::new(segment).components(); 37 match (components.next(), components.next()) { 38 (Some(std::path::Component::Normal(comp)), None) => { 39 let value = comp.to_string_lossy(); 40 if value.is_empty() { 41 None 42 } else { 43 Some(value.into_owned()) 44 } 45 } 46 _ => None, 47 } 48 } 49 50 #[derive(thiserror::Error, Debug)] 51 pub enum FileError { 52 #[error("Failed to create directory `{path}`: {source}")] 53 CreateDirError { 54 path: String, 55 #[source] 56 source: std::io::Error, 57 }, 58 #[error("Path join error: {0}")] 59 PathJoinError(#[from] PathsError), 60 #[error("I/O error: {0}")] 61 Io(#[from] std::io::Error), 62 } 63 64 pub fn fs_mkdir<S, I>(segments: I) -> Result<(), FileError> 65 where 66 I: IntoIterator<Item = S>, 67 S: AsRef<Path>, 68 { 69 let dir_path = paths_join(segments)?; 70 if !dir_path.exists() { 71 fs::create_dir_all(&dir_path).map_err(|e| FileError::CreateDirError { 72 path: dir_path.display().to_string(), 73 source: e, 74 })?; 75 debug!("Created directory: {}", dir_path.display()); 76 } else { 77 debug!("Directory already exists: {}", dir_path.display()); 78 } 79 Ok(()) 80 } 81 82 pub fn write_json<T: serde::Serialize>(path: &Path, data: &T) -> Result<()> { 83 let file = File::create(path)?; 84 let mut buf = std::io::BufWriter::new(file); 85 serde_json::to_writer_pretty(&mut buf, data)?; 86 buf.flush()?; 87 Ok(()) 88 } 89 90 pub fn write_hash(path: &Path, hash: &str) -> Result<()> { 91 let hash_path = path.with_extension("sha256.txt"); 92 fs::write(&hash_path, format!("{hash}\n"))?; 93 debug!(hash_path = %hash_path.display(), "Wrote new hash file"); 94 Ok(()) 95 } 96 97 pub fn write_json_if_changed<T: serde::Serialize>( 98 path: &Path, 99 data: &T, 100 updated: &mut Vec<PathBuf>, 101 ) -> Result<String> { 102 let hash = compute_hash(data) 103 .with_context(|| format!("Failed to hash JSON for {}", path.display()))?; 104 let hash_path = path.with_extension("sha256.txt"); 105 106 let needs_write = if path.exists() && hash_path.exists() { 107 let stored = fs::read_to_string(&hash_path) 108 .with_context(|| format!("Failed to read {}", hash_path.display()))?; 109 stored.trim() != hash 110 } else { 111 true 112 }; 113 114 if needs_write { 115 write_json(path, data) 116 .with_context(|| format!("Failed to write {}", path.display()))?; 117 write_hash(path, &hash) 118 .with_context(|| format!("Failed to write hash for {}", path.display()))?; 119 updated.push(path.to_path_buf()); 120 } 121 122 Ok(hash) 123 } 124 125 pub fn fs_write_rss(path: &Path, content: &str) -> Result<()> { 126 let mut file = File::create(path)?; 127 file.write_all(content.as_bytes())?; 128 Ok(()) 129 } 130 131 #[cfg(test)] 132 mod tests { 133 use super::{safe_path_segment, write_json_if_changed}; 134 use std::fs; 135 use tempfile::tempdir; 136 137 #[test] 138 fn safe_path_segment_rejects_traversal() { 139 assert!(safe_path_segment("..").is_none()); 140 assert!(safe_path_segment(".").is_none()); 141 assert!(safe_path_segment("a/b").is_none()); 142 assert!(safe_path_segment("/abs").is_none()); 143 } 144 145 #[test] 146 fn safe_path_segment_accepts_normal() { 147 assert_eq!(safe_path_segment("alpha"), Some("alpha".to_string())); 148 assert_eq!( 149 safe_path_segment("user@example.com"), 150 Some("user@example.com".to_string()) 151 ); 152 } 153 154 #[test] 155 fn write_json_if_changed_is_idempotent() { 156 let dir = tempdir().expect("tempdir"); 157 let path = dir.path().join("data.json"); 158 let mut updated = Vec::new(); 159 160 let hash_a = write_json_if_changed(&path, &vec![1u32, 2, 3], &mut updated) 161 .expect("write json"); 162 assert_eq!(updated.len(), 1); 163 let first = fs::read_to_string(&path).expect("read data"); 164 165 updated.clear(); 166 let hash_b = write_json_if_changed(&path, &vec![1u32, 2, 3], &mut updated) 167 .expect("write json"); 168 assert_eq!(hash_a, hash_b); 169 assert!(updated.is_empty()); 170 let second = fs::read_to_string(&path).expect("read data"); 171 assert_eq!(first, second); 172 173 updated.clear(); 174 let _hash_c = write_json_if_changed(&path, &vec![1u32, 2, 4], &mut updated) 175 .expect("write json"); 176 assert_eq!(updated.len(), 1); 177 } 178 }