Skip to main content

strixonomy_core/
io.rs

1use crate::error::{Result, StrixonomyError};
2use std::fs::{self, File};
3use std::io::{Read, Write};
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7/// Read at most `max_bytes` from `path`, rejecting files larger than the cap.
8pub fn read_file_capped(path: &Path, max_bytes: u64) -> Result<Vec<u8>> {
9    let mut file = File::open(path).map_err(|e| StrixonomyError::Scanner(e.to_string()))?;
10    let metadata = file.metadata().map_err(|e| StrixonomyError::Scanner(e.to_string()))?;
11    if metadata.len() > max_bytes {
12        return Err(StrixonomyError::Scanner(format!(
13            "file exceeds size limit ({} bytes > {max_bytes}): {}",
14            metadata.len(),
15            path.display()
16        )));
17    }
18
19    let mut buf = Vec::with_capacity(metadata.len() as usize);
20    let mut chunk = [0u8; 8192];
21    loop {
22        let n = file.read(&mut chunk).map_err(|e| StrixonomyError::Scanner(e.to_string()))?;
23        if n == 0 {
24            break;
25        }
26        if buf.len() + n > max_bytes as usize {
27            return Err(StrixonomyError::Scanner(format!(
28                "file exceeds size limit (> {max_bytes} bytes): {}",
29                path.display()
30            )));
31        }
32        buf.extend_from_slice(&chunk[..n]);
33    }
34    Ok(buf)
35}
36
37/// UTF-8 decode a capped file read. Uses [`MAX_FILE_BYTES`] when `max_bytes` is not needed elsewhere.
38pub fn read_to_string_capped(path: &Path, max_bytes: u64) -> Result<String> {
39    let bytes = read_file_capped(path, max_bytes)?;
40    String::from_utf8(bytes)
41        .map_err(|e| StrixonomyError::Scanner(format!("invalid UTF-8 in {}: {e}", path.display())))
42}
43
44/// Atomically replace `path` with `contents`.
45///
46/// Writes to a sibling temp file, preserves the destination's permission bits when it
47/// already exists (#422), then renames into place (with a Windows-safe replace fallback).
48/// Best-effort removes the temp file on mid-write failure (#343).
49///
50/// ACL / extended attributes are not preserved (platform-specific; mode bits only).
51pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
52    let parent =
53        path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
54    fs::create_dir_all(parent)?;
55    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
56    let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("file");
57    let tmp_path = parent.join(format!(".strixonomy-{stem}-{nanos}.tmp"));
58    // Best-effort remove temp on mid-write failure (#343).
59    let write_result = (|| -> std::io::Result<()> {
60        let mut file = fs::File::create(&tmp_path)?;
61        file.write_all(contents.as_bytes())?;
62        file.sync_all()?;
63        // Re-read destination mode immediately before chmod so a file that
64        // appears (or whose mode changes) during the temp write is still honored (#422).
65        if let Ok(meta) = fs::metadata(path) {
66            fs::set_permissions(&tmp_path, meta.permissions())?;
67        }
68        Ok(())
69    })();
70    if let Err(e) = write_result {
71        let _ = fs::remove_file(&tmp_path);
72        return Err(e);
73    }
74    replace_file(&tmp_path, path)
75}
76
77/// Replace `path` with `tmp_path` (tmp is consumed). Works on Windows where `rename` cannot
78/// overwrite an existing destination.
79pub fn replace_file(tmp_path: &Path, path: &Path) -> std::io::Result<()> {
80    match fs::rename(tmp_path, path) {
81        Ok(()) => Ok(()),
82        Err(_) if path.exists() => {
83            // Windows (and some network FS): rename refuses to replace. Move the existing
84            // file aside, then rename; restore on failure.
85            let bak_path = tmp_path.with_extension("bak");
86            fs::rename(path, &bak_path)?;
87            match fs::rename(tmp_path, path) {
88                Ok(()) => {
89                    let _ = fs::remove_file(&bak_path);
90                    Ok(())
91                }
92                Err(rename_err) => {
93                    let _ = fs::rename(&bak_path, path);
94                    let _ = fs::remove_file(tmp_path);
95                    Err(rename_err)
96                }
97            }
98        }
99        Err(e) => {
100            let _ = fs::remove_file(tmp_path);
101            Err(e)
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::fs;
110    use std::io::Write;
111    #[cfg(unix)]
112    use std::os::unix::fs::PermissionsExt;
113
114    #[test]
115    fn rejects_oversized_file_even_if_metadata_was_small() {
116        let dir = tempfile::tempdir().unwrap();
117        let path = dir.path().join("small.ttl");
118        {
119            let mut f = fs::File::create(&path).unwrap();
120            writeln!(f, "@prefix ex: <http://ex/> .").unwrap();
121        }
122        let content = read_file_capped(&path, 64).unwrap();
123        assert!(!content.is_empty());
124
125        let over = dir.path().join("over.ttl");
126        fs::write(&over, vec![b'x'; 20]).unwrap();
127        let err = read_file_capped(&over, 10).unwrap_err().to_string();
128        assert!(err.contains("size limit"));
129    }
130
131    #[test]
132    fn atomic_write_overwrites_existing_file() {
133        let dir = tempfile::tempdir().unwrap();
134        let path = dir.path().join("onto.ttl");
135        fs::write(&path, "old\n").unwrap();
136        atomic_write(&path, "new\n").expect("atomic write");
137        assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
138        let leftovers: Vec<_> = fs::read_dir(dir.path())
139            .unwrap()
140            .filter_map(|e| e.ok())
141            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
142            .collect();
143        assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
144    }
145
146    #[cfg(unix)]
147    #[test]
148    fn atomic_write_preserves_unix_mode() {
149        let dir = tempfile::tempdir().unwrap();
150        let path = dir.path().join("secret.ttl");
151        fs::write(&path, "private\n").unwrap();
152        let mut perms = fs::metadata(&path).unwrap().permissions();
153        perms.set_mode(0o600);
154        fs::set_permissions(&path, perms).unwrap();
155        assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
156
157        atomic_write(&path, "still private\n").expect("atomic write");
158        assert_eq!(fs::read_to_string(&path).unwrap(), "still private\n");
159        assert_eq!(
160            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
161            0o600,
162            "mode must survive atomic replace (#422)"
163        );
164    }
165
166    #[test]
167    fn replace_file_removes_tmp_when_rename_fails_and_dest_missing() {
168        let dir = tempfile::tempdir().unwrap();
169        let tmp = dir.path().join(".strixonomy-missing.tmp");
170        let dest = dir.path().join("out.ttl");
171        let err = replace_file(&tmp, &dest);
172        assert!(err.is_err());
173        assert!(!tmp.exists());
174    }
175
176    #[test]
177    fn replace_file_restores_dest_and_cleans_tmp_when_second_rename_fails() {
178        let dir = tempfile::tempdir().unwrap();
179        let dest = dir.path().join("out.ttl");
180        fs::write(&dest, "original\n").unwrap();
181        let tmp = dir.path().join(".strixonomy-out.tmp");
182        let err = replace_file(&tmp, &dest);
183        assert!(err.is_err());
184        assert_eq!(fs::read_to_string(&dest).unwrap(), "original\n");
185        assert!(!tmp.exists());
186        let bak = tmp.with_extension("bak");
187        assert!(!bak.exists(), "backup must not be left behind");
188    }
189}