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
7pub 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
37pub 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
44pub 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 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 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
77pub 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 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}