tangle_indexer


git clone https://radroots.dev/git/tangle_indexer.git
Log | Files | Refs | Submodules | LICENSE

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 }