1use std::ffi::OsString;
23use std::fmt;
24use std::path::{Component, Path, PathBuf};
25
26#[derive(Debug)]
28pub enum ConfinedWriteError {
29 InvalidPath(PathBuf),
31 Symlink(PathBuf),
33 NotADirectory(PathBuf),
35 NotAFile(PathBuf),
37 Io(PathBuf, std::io::Error),
38}
39
40impl fmt::Display for ConfinedWriteError {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::InvalidPath(p) => write!(
44 f,
45 "refusing write: {} is not a relative path of plain components",
46 p.display()
47 ),
48 Self::Symlink(p) => write!(
49 f,
50 "refusing write: {} is a symlink; data paths must not contain symlinked components",
51 p.display()
52 ),
53 Self::NotADirectory(p) => {
54 write!(f, "refusing write: {} is not a directory", p.display())
55 }
56 Self::NotAFile(p) => write!(f, "refusing write: {} is not a regular file", p.display()),
57 Self::Io(p, e) => write!(f, "write failed at {}: {e}", p.display()),
58 }
59 }
60}
61
62impl std::error::Error for ConfinedWriteError {}
63
64fn plain_components(rel: &Path) -> Result<Vec<OsString>, ConfinedWriteError> {
66 let mut out = Vec::new();
67 for component in rel.components() {
68 match component {
69 Component::Normal(name) => out.push(name.to_os_string()),
70 _ => return Err(ConfinedWriteError::InvalidPath(rel.to_path_buf())),
71 }
72 }
73 if out.is_empty() {
74 return Err(ConfinedWriteError::InvalidPath(rel.to_path_buf()));
75 }
76 Ok(out)
77}
78
79fn is_link(meta: &std::fs::Metadata) -> bool {
80 if meta.file_type().is_symlink() {
81 return true;
82 }
83 #[cfg(windows)]
84 {
85 use std::os::windows::fs::MetadataExt;
86 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
87 if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
88 return true;
89 }
90 }
91 false
92}
93
94pub fn rebuild_below(root: &Path, rel: &Path) -> Result<PathBuf, ConfinedWriteError> {
101 let names = plain_components(rel)?;
102 let last = names.len() - 1;
103 let mut current = root.to_path_buf();
104 for (index, name) in names.iter().enumerate() {
105 current.push(name);
106 match std::fs::symlink_metadata(¤t) {
107 Ok(meta) if is_link(&meta) => return Err(ConfinedWriteError::Symlink(current)),
108 Ok(meta) if index < last && !meta.is_dir() => {
109 return Err(ConfinedWriteError::NotADirectory(current));
110 }
111 Ok(meta) if index == last && !meta.is_file() && !meta.is_dir() => {
112 return Err(ConfinedWriteError::NotAFile(current));
113 }
114 Ok(_) => {}
115 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
116 current.extend(&names[index + 1..]);
118 return Ok(current);
119 }
120 Err(e) => return Err(ConfinedWriteError::Io(current, e)),
121 }
122 }
123 Ok(current)
124}
125
126pub fn write_below(
130 root: &Path,
131 rel: &Path,
132 bytes: &[u8],
133 mode: u32,
134) -> Result<PathBuf, ConfinedWriteError> {
135 let destination = rebuild_below(root, rel)?;
138 let names = plain_components(rel)?;
139 imp::write(root, &names, bytes, mode)?;
140 Ok(destination)
141}
142
143fn temp_name(name: &std::ffi::OsStr) -> OsString {
144 use std::sync::atomic::{AtomicU64, Ordering};
145 static COUNTER: AtomicU64 = AtomicU64::new(0);
146 let nanos = std::time::SystemTime::now()
147 .duration_since(std::time::UNIX_EPOCH)
148 .map_or(0, |d| d.subsec_nanos());
149 let mut out = OsString::from(".");
150 out.push(name);
151 out.push(format!(
152 ".{}.{nanos}.{}.tmp",
153 std::process::id(),
154 COUNTER.fetch_add(1, Ordering::Relaxed)
155 ));
156 out
157}
158
159#[cfg(unix)]
160mod imp {
161 use super::{ConfinedWriteError, temp_name};
162 use std::ffi::{CString, OsStr, OsString};
163 use std::io::Write;
164 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
165 use std::os::unix::ffi::OsStrExt;
166 use std::path::{Path, PathBuf};
167
168 fn c_name(name: &OsStr, at: &Path) -> Result<CString, ConfinedWriteError> {
169 CString::new(name.as_bytes()).map_err(|_| ConfinedWriteError::InvalidPath(at.to_path_buf()))
170 }
171
172 fn last_error(at: &Path) -> ConfinedWriteError {
173 ConfinedWriteError::Io(at.to_path_buf(), std::io::Error::last_os_error())
174 }
175
176 fn lstat_at(
178 dir: &OwnedFd,
179 name: &CString,
180 at: &Path,
181 ) -> Result<Option<libc::stat>, ConfinedWriteError> {
182 let mut st = std::mem::MaybeUninit::<libc::stat>::uninit();
183 let rc = unsafe {
186 libc::fstatat(
187 dir.as_raw_fd(),
188 name.as_ptr(),
189 st.as_mut_ptr(),
190 libc::AT_SYMLINK_NOFOLLOW,
191 )
192 };
193 if rc == 0 {
194 return Ok(Some(unsafe { st.assume_init() }));
196 }
197 let error = std::io::Error::last_os_error();
198 if error.kind() == std::io::ErrorKind::NotFound {
199 return Ok(None);
200 }
201 Err(ConfinedWriteError::Io(at.to_path_buf(), error))
202 }
203
204 fn classify(st: &libc::stat, at: &Path, want_dir: bool) -> Result<(), ConfinedWriteError> {
205 let kind = st.st_mode & libc::S_IFMT;
206 if kind == libc::S_IFLNK {
207 return Err(ConfinedWriteError::Symlink(at.to_path_buf()));
208 }
209 if want_dir && kind != libc::S_IFDIR {
210 return Err(ConfinedWriteError::NotADirectory(at.to_path_buf()));
211 }
212 if !want_dir && kind != libc::S_IFREG {
213 return Err(ConfinedWriteError::NotAFile(at.to_path_buf()));
214 }
215 Ok(())
216 }
217
218 fn open_dir_at(dir: &OwnedFd, name: &CString) -> std::io::Result<OwnedFd> {
219 let fd = unsafe {
222 libc::openat(
223 dir.as_raw_fd(),
224 name.as_ptr(),
225 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
226 )
227 };
228 if fd < 0 {
229 return Err(std::io::Error::last_os_error());
230 }
231 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
233 }
234
235 fn descend(dir: &OwnedFd, name: &OsStr, at: &Path) -> Result<OwnedFd, ConfinedWriteError> {
238 let c = c_name(name, at)?;
239 for _ in 0..2 {
240 match open_dir_at(dir, &c) {
241 Ok(fd) => return Ok(fd),
242 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
243 let rc = unsafe { libc::mkdirat(dir.as_raw_fd(), c.as_ptr(), 0o700) };
246 if rc != 0 {
247 let error = std::io::Error::last_os_error();
248 if error.kind() != std::io::ErrorKind::AlreadyExists {
249 return Err(ConfinedWriteError::Io(at.to_path_buf(), error));
250 }
251 }
252 }
253 Err(e) => {
255 return match lstat_at(dir, &c, at)? {
256 Some(st) => {
257 classify(&st, at, true)?;
258 Err(ConfinedWriteError::Io(at.to_path_buf(), e))
259 }
260 None => Err(ConfinedWriteError::Io(at.to_path_buf(), e)),
261 };
262 }
263 }
264 }
265 Err(ConfinedWriteError::Io(
266 at.to_path_buf(),
267 std::io::Error::other("directory vanished while it was created"),
268 ))
269 }
270
271 pub(super) fn write(
272 root: &Path,
273 names: &[OsString],
274 bytes: &[u8],
275 mode: u32,
276 ) -> Result<(), ConfinedWriteError> {
277 let root_c = CString::new(root.as_os_str().as_bytes())
278 .map_err(|_| ConfinedWriteError::InvalidPath(root.to_path_buf()))?;
279 let fd = unsafe {
282 libc::open(
283 root_c.as_ptr(),
284 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
285 )
286 };
287 if fd < 0 {
288 return Err(last_error(root));
289 }
290 let mut dir = unsafe { OwnedFd::from_raw_fd(fd) };
292 let mut at = PathBuf::from(root);
293 let (file_name, parents) = names.split_last().expect("plain_components is never empty");
294 for name in parents {
295 at.push(name);
296 dir = descend(&dir, name, &at)?;
297 }
298 at.push(file_name);
299 let target = c_name(file_name, &at)?;
300 if let Some(st) = lstat_at(&dir, &target, &at)? {
301 classify(&st, &at, false)?;
302 }
303
304 let tmp = temp_name(file_name);
305 let tmp_c = c_name(&tmp, &at)?;
306 let fd = unsafe {
309 libc::openat(
310 dir.as_raw_fd(),
311 tmp_c.as_ptr(),
312 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
313 mode as libc::c_uint,
314 )
315 };
316 if fd < 0 {
317 return Err(last_error(&at));
318 }
319 let mut file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) });
321 let staged = file
322 .write_all(bytes)
323 .and_then(|()| file.sync_all())
324 .map_err(|e| ConfinedWriteError::Io(at.clone(), e));
325 drop(file);
326 let unlink_tmp = || {
327 unsafe { libc::unlinkat(dir.as_raw_fd(), tmp_c.as_ptr(), 0) };
329 };
330 if let Err(e) = staged {
331 unlink_tmp();
332 return Err(e);
333 }
334 let rc = unsafe {
338 libc::renameat(
339 dir.as_raw_fd(),
340 tmp_c.as_ptr(),
341 dir.as_raw_fd(),
342 target.as_ptr(),
343 )
344 };
345 if rc != 0 {
346 let error = last_error(&at);
347 unlink_tmp();
348 return Err(error);
349 }
350 unsafe { libc::fsync(dir.as_raw_fd()) };
352 Ok(())
353 }
354}
355
356#[cfg(not(unix))]
357mod imp {
358 use super::{ConfinedWriteError, rebuild_below, temp_name};
359 use std::ffi::OsString;
360 use std::path::{Path, PathBuf};
361
362 pub(super) fn write(
365 root: &Path,
366 names: &[OsString],
367 bytes: &[u8],
368 _mode: u32,
369 ) -> Result<(), ConfinedWriteError> {
370 let rel: PathBuf = names.iter().collect();
371 let (file_name, parents) = names.split_last().expect("plain_components is never empty");
372 let mut dir = root.to_path_buf();
373 for name in parents {
374 dir.push(name);
375 match std::fs::create_dir(&dir) {
376 Ok(()) => {}
377 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
378 Err(e) => return Err(ConfinedWriteError::Io(dir, e)),
379 }
380 rebuild_below(root, &rel)?;
381 }
382 let target = dir.join(file_name);
383 let tmp = dir.join(temp_name(file_name));
384 let mut options = std::fs::OpenOptions::new();
385 options.write(true).create_new(true);
386 let staged = options.open(&tmp).and_then(|mut file| {
387 use std::io::Write;
388 file.write_all(bytes)?;
389 file.sync_all()
390 });
391 if let Err(e) = staged {
392 let _ = std::fs::remove_file(&tmp);
393 return Err(ConfinedWriteError::Io(target, e));
394 }
395 if let Err(e) = rebuild_below(root, &rel) {
396 let _ = std::fs::remove_file(&tmp);
397 return Err(e);
398 }
399 crate::atomic_fs::replace_file(&tmp, &target).map_err(|e| {
400 let _ = std::fs::remove_file(&tmp);
401 ConfinedWriteError::Io(target, e)
402 })
403 }
404}
405
406#[cfg(all(test, unix))]
407mod tests {
408 use super::*;
409 use std::os::unix::fs::symlink;
410
411 #[test]
412 fn writes_and_creates_missing_directories() {
413 let root = tempfile::tempdir().unwrap();
414 let path = write_below(root.path(), Path::new("a/b/c.json"), b"{}", 0o600).unwrap();
415 assert_eq!(path, root.path().join("a/b/c.json"));
416 assert_eq!(std::fs::read(&path).unwrap(), b"{}");
417 write_below(root.path(), Path::new("a/b/c.json"), b"[]", 0o600).unwrap();
419 assert_eq!(std::fs::read(&path).unwrap(), b"[]");
420 let leftovers: Vec<_> = std::fs::read_dir(root.path().join("a/b"))
421 .unwrap()
422 .flatten()
423 .map(|e| e.file_name())
424 .collect();
425 assert_eq!(leftovers.len(), 1, "no staged temp left: {leftovers:?}");
426 }
427
428 #[test]
429 fn a_trusted_root_may_be_a_link() {
430 let tmp = tempfile::tempdir().unwrap();
431 let real = tmp.path().join("real");
432 std::fs::create_dir(&real).unwrap();
433 let link = tmp.path().join("link");
434 symlink(&real, &link).unwrap();
435 write_below(&link, Path::new("x/y"), b"ok", 0o600).unwrap();
436 assert_eq!(std::fs::read(real.join("x/y")).unwrap(), b"ok");
437 }
438
439 #[test]
440 fn a_symlinked_parent_directory_is_refused() {
441 let tmp = tempfile::tempdir().unwrap();
442 let root = tmp.path().join("root");
443 let outside = tmp.path().join("outside");
444 std::fs::create_dir_all(&root).unwrap();
445 std::fs::create_dir_all(&outside).unwrap();
446 symlink(&outside, root.join("a")).unwrap();
447 let err = write_below(&root, Path::new("a/f"), b"x", 0o600).unwrap_err();
448 assert!(matches!(err, ConfinedWriteError::Symlink(ref p) if p == &root.join("a")));
449 assert!(!outside.join("f").exists());
450 }
451
452 #[test]
453 fn a_symlinked_target_file_is_refused() {
454 let tmp = tempfile::tempdir().unwrap();
455 let root = tmp.path().join("root");
456 std::fs::create_dir_all(root.join("d")).unwrap();
457 let victim = tmp.path().join("victim");
458 std::fs::write(&victim, b"keep").unwrap();
459 symlink(&victim, root.join("d/f")).unwrap();
460 let err = write_below(&root, Path::new("d/f"), b"x", 0o600).unwrap_err();
461 assert!(matches!(err, ConfinedWriteError::Symlink(_)), "{err}");
462 assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
463 assert!(
464 std::fs::symlink_metadata(root.join("d/f"))
465 .unwrap()
466 .file_type()
467 .is_symlink()
468 );
469 }
470
471 #[test]
472 fn a_dangling_symlink_is_refused() {
473 let tmp = tempfile::tempdir().unwrap();
474 let root = tmp.path().join("root");
475 std::fs::create_dir_all(&root).unwrap();
476 let missing = tmp.path().join("missing");
477 symlink(&missing, root.join("d")).unwrap();
478 symlink(tmp.path().join("missing-file"), root.join("f")).unwrap();
479 assert!(matches!(
480 write_below(&root, Path::new("d/f"), b"x", 0o600),
481 Err(ConfinedWriteError::Symlink(_))
482 ));
483 assert!(matches!(
484 write_below(&root, Path::new("f"), b"x", 0o600),
485 Err(ConfinedWriteError::Symlink(_))
486 ));
487 assert!(!missing.exists());
488 assert!(!tmp.path().join("missing-file").exists());
489 }
490
491 #[test]
492 fn non_plain_relative_paths_are_refused() {
493 let root = tempfile::tempdir().unwrap();
494 for rel in ["", "../x", "a/../b", "/etc/passwd", "./a"] {
495 assert!(
496 matches!(
497 write_below(root.path(), Path::new(rel), b"x", 0o600),
498 Err(ConfinedWriteError::InvalidPath(_))
499 ),
500 "{rel}"
501 );
502 }
503 }
504
505 #[test]
506 fn a_directory_at_the_destination_is_refused() {
507 let root = tempfile::tempdir().unwrap();
508 std::fs::create_dir_all(root.path().join("d")).unwrap();
509 assert!(matches!(
510 write_below(root.path(), Path::new("d"), b"x", 0o600),
511 Err(ConfinedWriteError::NotAFile(_))
512 ));
513 }
514
515 #[test]
518 fn the_handle_walk_refuses_a_link_swapped_in_after_the_check() {
519 let tmp = tempfile::tempdir().unwrap();
520 let root = tmp.path().join("root");
521 let outside = tmp.path().join("outside");
522 std::fs::create_dir_all(&root).unwrap();
523 std::fs::create_dir_all(&outside).unwrap();
524 symlink(&outside, root.join("a")).unwrap();
525 let names = plain_components(Path::new("a/f")).unwrap();
526 let err = imp::write(&root, &names, b"x", 0o600).unwrap_err();
527 assert!(matches!(err, ConfinedWriteError::Symlink(_)), "{err}");
528 assert!(!outside.join("f").exists());
529 }
530}