1use std::io;
45use std::path::{Path, PathBuf};
46
47#[cfg(unix)]
49const DIR_MODE: u32 = 0o700;
50#[cfg(unix)]
52const FILE_MODE: u32 = 0o600;
53
54pub(crate) fn create_dir_all_private(path: &Path) -> io::Result<()> {
61 #[cfg(unix)]
62 {
63 use std::os::unix::fs::DirBuilderExt as _;
64 std::fs::DirBuilder::new()
65 .recursive(true)
66 .mode(DIR_MODE)
67 .create(path)
68 }
69 #[cfg(not(unix))]
70 {
71 std::fs::create_dir_all(path)
72 }
73}
74
75pub(crate) fn harden_dir(path: &Path) {
82 #[cfg(unix)]
83 {
84 use std::os::unix::fs::PermissionsExt as _;
85 let Ok(meta) = std::fs::metadata(path) else {
86 return;
87 };
88 let mode = meta.permissions().mode();
89 if mode & 0o7777 & !DIR_MODE != 0 {
90 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(DIR_MODE));
91 }
92 }
93 #[cfg(not(unix))]
94 {
95 let _ = path;
96 }
97}
98
99pub(crate) fn create_new_private(path: &Path) -> io::Result<std::fs::File> {
104 let mut options = std::fs::OpenOptions::new();
105 options.write(true).create_new(true);
106 #[cfg(unix)]
107 {
108 use std::os::unix::fs::OpenOptionsExt as _;
109 options.mode(FILE_MODE);
110 }
111 options.open(path)
112}
113
114pub(crate) fn harden_file(path: &Path) {
118 #[cfg(unix)]
119 {
120 use std::os::unix::fs::PermissionsExt as _;
121 let Ok(meta) = std::fs::metadata(path) else {
122 return;
123 };
124 if meta.permissions().mode() & 0o7777 & !FILE_MODE != 0 {
125 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE));
126 }
127 }
128 #[cfg(not(unix))]
129 {
130 let _ = path;
131 }
132}
133
134pub const TEMP_INFIX: &str = ".faultbox-";
141
142static TEMP_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
145
146pub(crate) fn create_temp_beside(
153 final_path: &Path,
154 tag: &str,
155) -> io::Result<(std::fs::File, PathBuf)> {
156 let mut last = None;
157 for _ in 0..64 {
158 let path = temp_path(final_path, tag);
159 match create_new_private(&path) {
160 Ok(file) => return Ok((file, path)),
161 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
162 Err(e) => return Err(e),
163 }
164 }
165 Err(last.unwrap_or_else(|| {
166 io::Error::new(
167 io::ErrorKind::AlreadyExists,
168 "could not find a free temporary name",
169 )
170 }))
171}
172
173pub(crate) fn temp_path(final_path: &Path, tag: &str) -> PathBuf {
176 let sequence = TEMP_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
177 let mut name = final_path.file_name().map_or_else(
178 || std::ffi::OsString::from("faultbox"),
179 std::ffi::OsStr::to_os_string,
180 );
181 name.push(format!(
182 "{TEMP_INFIX}{tag}.{}.{sequence}.{}",
183 std::process::id(),
184 crate::now_ms()
185 ));
186 match final_path.parent() {
187 Some(parent) => parent.join(name),
188 None => PathBuf::from(name),
189 }
190}
191
192pub(crate) fn create_temp_dir_beside(final_path: &Path, tag: &str) -> io::Result<PathBuf> {
195 let mut last = None;
196 for _ in 0..64 {
197 let path = temp_path(final_path, tag);
198 match create_dir_exclusive(&path) {
199 Ok(()) => return Ok(path),
200 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
201 Err(e) => return Err(e),
202 }
203 }
204 Err(last.unwrap_or_else(|| {
205 io::Error::new(
206 io::ErrorKind::AlreadyExists,
207 "could not find a free temporary name",
208 )
209 }))
210}
211
212pub(crate) fn create_dir_exclusive(path: &Path) -> io::Result<()> {
214 #[cfg(unix)]
215 {
216 use std::os::unix::fs::DirBuilderExt as _;
217 std::fs::DirBuilder::new()
218 .recursive(false)
219 .mode(DIR_MODE)
220 .create(path)
221 }
222 #[cfg(not(unix))]
223 {
224 std::fs::DirBuilder::new().recursive(false).create(path)
225 }
226}
227
228const RESERVED_NAMES: &[&str] = &[".lock", "report.json", "latest.json", "minidump"];
232
233pub fn validate_artifact_name(name: &str) -> io::Result<()> {
246 let reject = |why: &str| {
247 Err(io::Error::new(
248 io::ErrorKind::InvalidInput,
249 format!("invalid artifact name {name:?}: {why}"),
250 ))
251 };
252
253 if name.is_empty() {
254 return reject("empty");
255 }
256 if name.len() > 255 {
257 return reject("longer than 255 bytes");
258 }
259 if name == "." || name == ".." {
260 return reject("a path traversal component");
261 }
262 if name.starts_with('.') {
263 return reject("names beginning with a dot are reserved");
264 }
265 if !name
266 .bytes()
267 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
268 {
269 return reject("must be a single path component of ASCII letters, digits, '.', '_' or '-'");
270 }
271 if RESERVED_NAMES.iter().any(|r| name.eq_ignore_ascii_case(r)) {
272 return reject("reserved for the recorder's own files");
273 }
274 if name.contains(TEMP_INFIX) {
275 return reject("reserved: it marks the recorder's own temporary files");
276 }
277 Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn traversal_and_absolute_names_are_refused() {
286 for name in [
287 "../escape",
288 "../../escape",
289 "a/b",
290 "a\\b",
291 "/etc/passwd",
292 "C:\\windows",
293 "stream:ads",
294 ".",
295 "..",
296 "",
297 ".hidden",
298 ] {
299 assert!(
300 validate_artifact_name(name).is_err(),
301 "{name:?} must be refused"
302 );
303 }
304 }
305
306 #[test]
307 fn the_recorders_own_files_cannot_be_claimed() {
308 for name in [".lock", "report.json", "latest.json", "REPORT.JSON"] {
309 assert!(
310 validate_artifact_name(name).is_err(),
311 "{name:?} must be refused"
312 );
313 }
314 }
315
316 #[test]
317 fn a_nul_byte_cannot_hide_inside_a_name() {
318 assert!(validate_artifact_name("snap\0.db").is_err());
319 }
320
321 #[test]
322 fn ordinary_artifact_names_are_accepted() {
323 for name in [
324 "snap",
325 "store.corrupt",
326 "main.db",
327 "seg-0_1.bin",
328 "db.tmp.1",
330 "restore.incoming.2",
331 ] {
332 assert!(
333 validate_artifact_name(name).is_ok(),
334 "{name:?} must be accepted"
335 );
336 }
337 }
338
339 #[test]
342 fn an_artifact_cannot_carry_the_temporary_marker() {
343 assert!(validate_artifact_name("snap.faultbox-incoming.1").is_err());
344 assert!(
345 temp_path(Path::new("/r/snap.db"), "incoming")
346 .file_name()
347 .unwrap()
348 .to_string_lossy()
349 .contains(TEMP_INFIX),
350 "the sweep and the namer must agree on the marker"
351 );
352 }
353
354 #[cfg(unix)]
355 #[test]
356 fn created_directories_and_files_are_owner_only() {
357 use std::os::unix::fs::PermissionsExt as _;
358
359 let tmp = tempfile::tempdir().unwrap();
360 let dir = tmp.path().join("nested/group");
361 create_dir_all_private(&dir).unwrap();
362 assert_eq!(
363 std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
364 0o700
365 );
366
367 let (file, path) = create_temp_beside(&dir.join("report.json"), "tmp").unwrap();
368 drop(file);
369 assert_eq!(
370 std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777,
371 0o600
372 );
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn a_planted_symlink_is_refused_rather_than_followed() {
378 let tmp = tempfile::tempdir().unwrap();
379 let victim = tmp.path().join("victim");
380 std::fs::write(&victim, b"original").unwrap();
381
382 let planted = tmp.path().join("planted");
383 std::os::unix::fs::symlink(&victim, &planted).unwrap();
384
385 let err = create_new_private(&planted).expect_err("must refuse a symlink");
386 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
387 assert_eq!(
388 std::fs::read(&victim).unwrap(),
389 b"original",
390 "the target must not have been written through"
391 );
392 }
393
394 #[cfg(unix)]
395 #[test]
396 fn harden_dir_narrows_a_world_readable_directory() {
397 use std::os::unix::fs::PermissionsExt as _;
398
399 let tmp = tempfile::tempdir().unwrap();
400 let dir = tmp.path().join("legacy");
401 std::fs::create_dir(&dir).unwrap();
402 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
403
404 harden_dir(&dir);
405 assert_eq!(
406 std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
407 0o700
408 );
409 }
410
411 #[test]
412 fn temporary_names_do_not_repeat() {
413 let base = Path::new("/tmp/reports/report.json");
414 let a = temp_path(base, "tmp");
415 let b = temp_path(base, "tmp");
416 assert_ne!(a, b, "two temporaries must never collide");
417 assert_eq!(a.parent(), base.parent(), "staged beside the destination");
418 }
419}