1use std::ffi::OsStr;
40use std::fmt;
41use std::fs::File;
42use std::io::{Read, Write};
43use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
44use std::os::unix::ffi::OsStrExt;
45use std::path::{Path, PathBuf};
46use std::sync::{Arc, Mutex};
47
48use rustix::fs::{AtFlags, FileType, Mode, OFlags, RawMode};
49use rustix::io::Errno;
50use serde::Serialize;
51
52pub const DEFAULT_DIR_MODE: u32 = 0o777;
55
56pub const DEFAULT_FILE_MODE: u32 = 0o666;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum WorkdirOperation {
65 Write,
66 Append,
67 Read,
68 CreateDir,
69 Remove,
70}
71
72impl WorkdirOperation {
73 fn verb(self) -> &'static str {
74 match self {
75 Self::Write => "write",
76 Self::Append => "append to",
77 Self::Read => "read",
78 Self::CreateDir => "create directory",
79 Self::Remove => "remove",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RefusalReason {
88 Symlink,
90 HardLink,
93 NotARegularFile,
95 InvalidComponent,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct WorkdirRefusal {
102 pub path: String,
104 pub refused_at: String,
107 pub operation: WorkdirOperation,
108 pub reason: RefusalReason,
109}
110
111impl fmt::Display for WorkdirRefusal {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 let what = match self.reason {
114 RefusalReason::Symlink => "is a symbolic link inside the workdir",
115 RefusalReason::HardLink => {
116 "is a hard link with another name that may be outside the workdir"
117 }
118 RefusalReason::NotARegularFile => "is not a regular file",
119 RefusalReason::InvalidComponent => "is not a plain relative path inside the workdir",
120 };
121 write!(
122 f,
123 "refused to {} {}: {} {what}",
124 self.operation.verb(),
125 self.path,
126 self.refused_at
127 )
128 }
129}
130
131#[derive(Debug, thiserror::Error)]
133pub enum WorkdirError {
134 #[error("{0}")]
136 Refused(WorkdirRefusal),
137 #[error("{path}: {source}")]
140 Io {
141 path: String,
142 #[source]
143 source: std::io::Error,
144 },
145}
146
147impl WorkdirError {
148 pub fn is_not_found(&self) -> bool {
150 matches!(self, Self::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
151 }
152}
153
154impl From<WorkdirError> for std::io::Error {
155 fn from(error: WorkdirError) -> Self {
158 match error {
159 WorkdirError::Refused(refusal) => {
160 std::io::Error::new(std::io::ErrorKind::PermissionDenied, refusal.to_string())
161 }
162 WorkdirError::Io { source, .. } => source,
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
169pub enum EntryKind {
170 File,
171 Directory,
172 Symlink,
173 Other,
174}
175
176impl EntryKind {
177 fn of(file_type: FileType) -> Self {
178 match file_type {
179 FileType::RegularFile => Self::File,
180 FileType::Directory => Self::Directory,
181 FileType::Symlink => Self::Symlink,
182 _ => Self::Other,
183 }
184 }
185}
186
187#[derive(Clone)]
193pub struct WorkdirRoot {
194 anchor: Arc<Anchor>,
195 latch: Arc<Mutex<Vec<WorkdirRefusal>>>,
196}
197
198struct Anchor {
199 fd: Mutex<Arc<OwnedFd>>,
201 path: PathBuf,
202 prefix: String,
204 origin: Origin,
205}
206
207enum Origin {
210 Path,
212 Beneath { from: WorkdirRoot, rel: String },
215}
216
217impl fmt::Debug for WorkdirRoot {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 f.debug_struct("WorkdirRoot")
220 .field("path", &self.anchor.path)
221 .field("prefix", &self.anchor.prefix)
222 .finish()
223 }
224}
225
226enum Held {
228 Anchor(Arc<OwnedFd>),
229 Opened(OwnedFd),
230}
231
232impl AsFd for Held {
233 fn as_fd(&self) -> BorrowedFd<'_> {
234 match self {
235 Self::Anchor(fd) => fd.as_fd(),
236 Self::Opened(fd) => fd.as_fd(),
237 }
238 }
239}
240
241const DIR_FLAGS: OFlags = OFlags::RDONLY
242 .union(OFlags::DIRECTORY)
243 .union(OFlags::NOFOLLOW)
244 .union(OFlags::CLOEXEC)
245 .union(OFlags::NONBLOCK);
246
247#[cfg(test)]
248thread_local! {
249 static BEFORE_RENAME: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
252 const { std::cell::RefCell::new(None) };
253}
254
255#[cfg(test)]
256thread_local! {
257 static NLINK_UNRELIABLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
260}
261
262fn nlink_unreliable() -> bool {
263 #[cfg(test)]
264 {
265 NLINK_UNRELIABLE.with(std::cell::Cell::get)
266 }
267 #[cfg(not(test))]
268 {
269 false
270 }
271}
272
273#[cfg(test)]
275pub(crate) fn before_next_rename(hook: impl FnOnce() + 'static) {
276 BEFORE_RENAME.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
277}
278
279fn open_by_path(path: &Path) -> Result<OwnedFd, WorkdirError> {
281 rustix::fs::open(path, DIR_FLAGS.difference(OFlags::NOFOLLOW), Mode::empty()).map_err(|errno| {
282 WorkdirError::Io {
283 path: path.display().to_string(),
284 source: errno.into(),
285 }
286 })
287}
288
289fn mode_of(mode: u32) -> Mode {
290 Mode::from_raw_mode(mode as RawMode)
291}
292
293impl WorkdirRoot {
294 pub fn open(path: &Path) -> Result<Self, WorkdirError> {
300 Ok(Self {
301 anchor: Arc::new(Anchor {
302 fd: Mutex::new(Arc::new(open_by_path(path)?)),
303 path: path.to_path_buf(),
304 prefix: String::new(),
305 origin: Origin::Path,
306 }),
307 latch: Arc::new(Mutex::new(Vec::new())),
308 })
309 }
310
311 pub fn open_beneath(&self, rel: &str) -> Result<Option<Self>, WorkdirError> {
314 let op = WorkdirOperation::Read;
315 let segments = self.segments(rel, op)?;
316 let Some(held) = self.walk(rel, &segments, segments.len(), op, None)? else {
317 return Ok(None);
318 };
319 let Held::Opened(fd) = held else {
320 unreachable!("a walk over at least one segment opens a descriptor")
321 };
322 Ok(Some(Self {
323 anchor: Arc::new(Anchor {
324 fd: Mutex::new(Arc::new(fd)),
325 path: self.anchor.path.join(rel),
326 prefix: self.display(rel),
327 origin: Origin::Beneath {
328 from: self.clone(),
329 rel: rel.to_string(),
330 },
331 }),
332 latch: Arc::clone(&self.latch),
333 }))
334 }
335
336 pub fn open_sharing_latch(path: &Path, latch_of: &WorkdirRoot) -> Result<Self, WorkdirError> {
340 let opened = Self::open(path)?;
341 Ok(Self {
342 anchor: opened.anchor,
343 latch: Arc::clone(&latch_of.latch),
344 })
345 }
346
347 pub fn open_sharing_latch_absolute(
351 path: &Path,
352 latch_of: &WorkdirRoot,
353 ) -> Result<Self, WorkdirError> {
354 let opened = Self::open(path)?;
355 Ok(Self {
356 anchor: Arc::new(Anchor {
357 fd: Mutex::new(opened.anchor_fd(false)?),
358 path: path.to_path_buf(),
359 prefix: path.display().to_string(),
360 origin: Origin::Path,
361 }),
362 latch: Arc::clone(&latch_of.latch),
363 })
364 }
365
366 pub fn identity(&self) -> Result<(u64, u64), WorkdirError> {
368 fd_identity(&*self.anchor_fd(false)?).map_err(|errno| self.io("", errno.into()))
369 }
370
371 pub fn dir_fd(&self) -> Result<OwnedFd, WorkdirError> {
374 let anchor = self.anchor_fd(false)?;
375 rustix::fs::openat(&*anchor, ".", DIR_FLAGS, Mode::empty())
376 .map_err(|errno| self.io("", errno.into()))
377 }
378
379 #[cfg(target_os = "linux")]
383 pub fn path_fd(&self) -> Result<OwnedFd, WorkdirError> {
384 let anchor = self.anchor_fd(false)?;
385 rustix::fs::openat(
386 &*anchor,
387 ".",
388 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
389 Mode::empty(),
390 )
391 .map_err(|errno| self.io("", errno.into()))
392 }
393
394 #[cfg(target_os = "linux")]
398 pub fn path_fd_beneath(&self, rel: impl AsRef<Path>) -> Result<OwnedFd, WorkdirError> {
399 let rel = rel.as_ref().to_string_lossy();
400 let op = WorkdirOperation::Read;
401 let (parent, name) = self.parent_of(&rel, op, false)?;
402 let Some(parent) = parent else {
403 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
404 };
405 let flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
406 let refused_or = |errno: Errno| match self.leaf_type(&parent, name, &rel) {
407 Ok(Some(FileType::Symlink)) => self.refuse(&rel, &rel, op, RefusalReason::Symlink),
408 Ok(_) => self.io(&rel, errno.into()),
409 Err(error) => error,
410 };
411 let fd = rustix::fs::openat(&parent, name, flags, Mode::empty()).map_err(refused_or)?;
412 let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(&rel, errno.into()))?;
415 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
416 return Err(refused_or(Errno::NOTDIR));
417 }
418 Ok(fd)
419 }
420
421 pub fn path(&self) -> &Path {
424 &self.anchor.path
425 }
426
427 pub fn host_path(&self, rel: impl AsRef<Path>) -> PathBuf {
429 self.anchor.path.join(rel)
430 }
431
432 pub fn display_path(&self, rel: impl AsRef<Path>) -> String {
434 self.display(&rel.as_ref().to_string_lossy())
435 }
436
437 pub fn owner(&self) -> Result<(u32, u32), WorkdirError> {
439 let stat = rustix::fs::fstat(&*self.anchor_fd(false)?)
440 .map_err(|errno| self.io("", errno.into()))?;
441 Ok((stat.st_uid, stat.st_gid))
442 }
443
444 pub fn take_refusals(&self) -> Vec<WorkdirRefusal> {
446 std::mem::take(&mut *self.latch.lock().unwrap_or_else(|e| e.into_inner()))
447 }
448
449 pub fn write_file(
456 &self,
457 rel: impl AsRef<Path>,
458 bytes: &[u8],
459 mode: u32,
460 ) -> Result<(), WorkdirError> {
461 self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, false))
462 }
463
464 fn write_file_inner(
465 &self,
466 rel: &Path,
467 bytes: &[u8],
468 mode: u32,
469 exact_mode: bool,
470 ) -> Result<(), WorkdirError> {
471 let rel = rel.to_string_lossy();
472 let op = WorkdirOperation::Write;
473 let (parent, name) = self.parent_of(&rel, op, true)?;
474 let parent = parent.expect("a creating walk reaches the parent");
475 match self.leaf_type(&parent, name, &rel)? {
476 Some(FileType::Symlink) => {
477 return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
478 }
479 Some(FileType::Directory) => {
480 return Err(self.io(&rel, std::io::ErrorKind::IsADirectory.into()));
481 }
482 _ => {}
483 }
484 let temp = format!(
485 ".{}.{}.tmp",
486 name.to_string_lossy(),
487 uuid::Uuid::now_v7().simple()
488 );
489 let flags =
490 OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
491 let fd = rustix::fs::openat(&parent, temp.as_str(), flags, mode_of(mode))
492 .map_err(|errno| self.io(&rel, errno.into()))?;
493 let chmod = if exact_mode {
494 rustix::fs::fchmod(&fd, mode_of(mode)).map_err(|errno| self.io(&rel, errno.into()))
495 } else {
496 Ok(())
497 };
498 let result = chmod
499 .and_then(|()| {
500 File::from(fd)
501 .write_all(bytes)
502 .map_err(|source| self.io(&rel, source))
503 })
504 .and_then(|()| {
505 #[cfg(test)]
506 if let Some(hook) = BEFORE_RENAME.with(|slot| slot.borrow_mut().take()) {
507 hook();
508 }
509 rustix::fs::renameat(&parent, temp.as_str(), &parent, name)
510 .map_err(|errno| self.io(&rel, errno.into()))
511 });
512 if result.is_err() {
513 let _ = rustix::fs::unlinkat(&parent, temp.as_str(), AtFlags::empty());
514 }
515 result
516 }
517
518 pub fn write_file_exact_mode(
521 &self,
522 rel: impl AsRef<Path>,
523 bytes: &[u8],
524 mode: u32,
525 ) -> Result<(), WorkdirError> {
526 self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, true))
527 }
528
529 pub fn overwrite_in_place(
534 &self,
535 rel: impl AsRef<Path>,
536 bytes: &[u8],
537 mode: u32,
538 ) -> Result<(), WorkdirError> {
539 let rel = rel.as_ref().to_string_lossy();
540 let op = WorkdirOperation::Write;
541 let flags = OFlags::WRONLY | OFlags::CREATE;
542 let mut file = self.retry_if_removed(|| self.open_regular(&rel, op, flags, mode, true))?;
543 file.set_len(0).map_err(|source| self.io(&rel, source))?;
544 file.write_all(bytes)
545 .map_err(|source| self.io(&rel, source))
546 }
547
548 pub fn open_append(&self, rel: impl AsRef<Path>, mode: u32) -> Result<File, WorkdirError> {
554 let rel = rel.as_ref().to_string_lossy();
555 let flags = OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE;
556 self.retry_if_removed(|| {
557 self.open_regular(&rel, WorkdirOperation::Append, flags, mode, true)
558 })
559 }
560
561 pub fn read_file(
566 &self,
567 rel: impl AsRef<Path>,
568 max_bytes: u64,
569 ) -> Result<Option<Vec<u8>>, WorkdirError> {
570 let rel = rel.as_ref().to_string_lossy();
571 let mut file =
572 match self.open_regular(&rel, WorkdirOperation::Read, OFlags::RDONLY, 0, false) {
573 Ok(file) => file,
574 Err(error) if error.is_not_found() => return Ok(None),
575 Err(error) => return Err(error),
576 };
577 let mut bytes = Vec::new();
578 Read::by_ref(&mut file)
579 .take(max_bytes.saturating_add(1))
580 .read_to_end(&mut bytes)
581 .map_err(|source| self.io(&rel, source))?;
582 if bytes.len() as u64 > max_bytes {
583 return Err(self.io(
584 &rel,
585 std::io::Error::new(
586 std::io::ErrorKind::InvalidData,
587 format!("larger than {max_bytes} bytes"),
588 ),
589 ));
590 }
591 Ok(Some(bytes))
592 }
593
594 pub fn read_to_string(&self, rel: impl AsRef<Path>) -> Result<Option<String>, WorkdirError> {
596 let rel = rel.as_ref();
597 let Some(bytes) = self.read_file(rel, u64::MAX)? else {
598 return Ok(None);
599 };
600 String::from_utf8(bytes).map(Some).map_err(|error| {
601 self.io(
602 &rel.to_string_lossy(),
603 std::io::Error::new(std::io::ErrorKind::InvalidData, error),
604 )
605 })
606 }
607
608 pub fn create_dir_all(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
611 let rel = rel.as_ref().to_string_lossy();
612 let op = WorkdirOperation::CreateDir;
613 let segments = self.segments(&rel, op)?;
614 self.retry_if_removed(|| self.walk(&rel, &segments, segments.len(), op, Some(mode)))
615 .map(|_| ())
616 }
617
618 pub fn set_dir_mode(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
621 let rel = rel.as_ref().to_string_lossy();
622 let op = WorkdirOperation::CreateDir;
623 let (parent, name) = self.parent_of(&rel, op, false)?;
624 let Some(parent) = parent else {
625 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
626 };
627 match self.leaf_type(&parent, name, &rel)? {
628 None => Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
629 Some(FileType::Symlink) => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
630 Some(FileType::Directory) => {
631 self.chmod_dir(&parent, name, &rel, |_| (mode & 0o7777) as RawMode)
632 }
633 Some(_) => Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
634 }
635 }
636
637 pub fn entry_kind(&self, rel: impl AsRef<Path>) -> Result<Option<EntryKind>, WorkdirError> {
641 let rel = rel.as_ref().to_string_lossy();
642 let op = WorkdirOperation::Read;
643 let (parent, name) = self.parent_of(&rel, op, false)?;
644 let Some(parent) = parent else {
645 return Ok(None);
646 };
647 Ok(self.leaf_type(&parent, name, &rel)?.map(EntryKind::of))
648 }
649
650 pub fn file_len(&self, rel: impl AsRef<Path>) -> Result<Option<u64>, WorkdirError> {
653 let rel = rel.as_ref().to_string_lossy();
654 let op = WorkdirOperation::Read;
655 let (parent, name) = self.parent_of(&rel, op, false)?;
656 let Some(parent) = parent else {
657 return Ok(None);
658 };
659 match rustix::fs::statat(&parent, name, AtFlags::SYMLINK_NOFOLLOW) {
660 Ok(stat) => match FileType::from_raw_mode(stat.st_mode) {
661 FileType::RegularFile => Ok(Some(stat.st_size as u64)),
662 FileType::Symlink => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
663 _ => Ok(None),
664 },
665 Err(Errno::NOENT) => Ok(None),
666 Err(errno) => Err(self.io(&rel, errno.into())),
667 }
668 }
669
670 pub fn list_dir(
674 &self,
675 rel: impl AsRef<Path>,
676 ) -> Result<Option<Vec<(String, EntryKind)>>, WorkdirError> {
677 let rel = rel.as_ref().to_string_lossy();
678 let op = WorkdirOperation::Read;
679 let held = if rel.is_empty() {
680 Some(Held::Anchor(self.anchor_fd(false)?))
681 } else {
682 let segments = self.segments(&rel, op)?;
683 self.walk(&rel, &segments, segments.len(), op, None)?
684 };
685 let Some(dir) = held else {
686 return Ok(None);
687 };
688 let mut entries = Vec::new();
689 for (name, file_type) in self.children(&dir, &rel)? {
690 entries.push((
691 name.to_string_lossy().into_owned(),
692 EntryKind::of(file_type),
693 ));
694 }
695 entries.sort();
696 Ok(Some(entries))
697 }
698
699 pub fn remove_file(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
702 let rel = rel.as_ref().to_string_lossy();
703 let op = WorkdirOperation::Remove;
704 let (parent, name) = self.parent_of(&rel, op, false)?;
705 let Some(parent) = parent else {
706 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
707 };
708 if self.leaf_type(&parent, name, &rel)? == Some(FileType::Symlink) {
709 return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
710 }
711 rustix::fs::unlinkat(&parent, name, AtFlags::empty())
712 .map_err(|errno| self.io(&rel, errno.into()))
713 }
714
715 pub fn remove_dir_all(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
721 let rel = rel.as_ref().to_string_lossy();
722 let op = WorkdirOperation::Remove;
723 let (parent, name) = self.parent_of(&rel, op, false)?;
724 let Some(parent) = parent else {
725 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
726 };
727 match self.leaf_type(&parent, name, &rel)? {
728 None => return Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
729 Some(FileType::Symlink) => {
730 return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
731 }
732 Some(FileType::Directory) => {}
733 Some(_) => return Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
734 }
735 self.grant_owner_rwx(&parent, name, &rel)?;
736 let dir = self.open_dir(&parent, name, &rel, &rel, op)?;
737 self.empty_dir(&dir, &rel)?;
738 rustix::fs::unlinkat(&parent, name, AtFlags::REMOVEDIR)
739 .map_err(|errno| self.io(&rel, errno.into()))
740 }
741
742 fn empty_dir(&self, dir: &OwnedFd, rel: &str) -> Result<(), WorkdirError> {
743 for (name, file_type) in self.children(dir, rel)? {
744 let child = format!("{rel}/{}", name.to_string_lossy());
745 if file_type == FileType::Directory {
746 self.grant_owner_rwx(dir, &name, &child)?;
747 let sub = self.open_dir(dir, &name, &child, &child, WorkdirOperation::Remove)?;
748 self.empty_dir(&sub, &child)?;
749 rustix::fs::unlinkat(dir, &name, AtFlags::REMOVEDIR)
750 } else {
751 rustix::fs::unlinkat(dir, &name, AtFlags::empty())
752 }
753 .map_err(|errno| self.io(&child, errno.into()))?;
754 }
755 Ok(())
756 }
757
758 fn grant_owner_rwx(
761 &self,
762 parent: &impl AsFd,
763 name: &OsStr,
764 rel: &str,
765 ) -> Result<(), WorkdirError> {
766 const OWNER_RWX: RawMode = 0o700;
767 self.chmod_dir(parent, name, rel, |mode| mode | OWNER_RWX)
768 }
769
770 fn chmod_dir(
773 &self,
774 parent: &impl AsFd,
775 name: &OsStr,
776 rel: &str,
777 new_mode: impl FnOnce(RawMode) -> RawMode,
778 ) -> Result<(), WorkdirError> {
779 #[cfg(target_os = "linux")]
783 {
784 use std::os::fd::AsRawFd;
785 let fd = rustix::fs::openat(
786 parent,
787 name,
788 OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
789 Mode::empty(),
790 )
791 .map_err(|errno| self.io(rel, errno.into()))?;
792 let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
793 let mode = stat.st_mode as RawMode & 0o7777;
794 let wanted = new_mode(mode);
795 if wanted == mode {
796 return Ok(());
797 }
798 rustix::fs::chmod(
799 format!("/proc/self/fd/{}", fd.as_raw_fd()),
800 Mode::from_raw_mode(wanted),
801 )
802 .map_err(|errno| self.io(rel, errno.into()))
803 }
804 #[cfg(not(target_os = "linux"))]
805 {
806 let stat = rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
807 .map_err(|errno| self.io(rel, errno.into()))?;
808 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
809 return Err(self.io(rel, std::io::ErrorKind::NotADirectory.into()));
810 }
811 let mode = stat.st_mode as RawMode & 0o7777;
812 let wanted = new_mode(mode);
813 if wanted == mode {
814 return Ok(());
815 }
816 rustix::fs::chmodat(
817 parent,
818 name,
819 Mode::from_raw_mode(wanted),
820 AtFlags::SYMLINK_NOFOLLOW,
821 )
822 .map_err(|errno| self.io(rel, errno.into()))
823 }
824 }
825
826 fn children(
829 &self,
830 dir: &impl AsFd,
831 rel: &str,
832 ) -> Result<Vec<(std::ffi::OsString, FileType)>, WorkdirError> {
833 let mut listing =
834 rustix::fs::Dir::read_from(dir).map_err(|errno| self.io(rel, errno.into()))?;
835 let mut names = Vec::new();
836 while let Some(entry) = listing.read() {
837 let entry = entry.map_err(|errno| self.io(rel, errno.into()))?;
838 let name = OsStr::from_bytes(entry.file_name().to_bytes());
839 if name == "." || name == ".." {
840 continue;
841 }
842 names.push(name.to_os_string());
843 }
844 let mut children = Vec::with_capacity(names.len());
845 for name in names {
846 match rustix::fs::statat(dir, &name, AtFlags::SYMLINK_NOFOLLOW) {
847 Ok(stat) => children.push((name, FileType::from_raw_mode(stat.st_mode))),
848 Err(Errno::NOENT) => {}
849 Err(errno) => return Err(self.io(rel, errno.into())),
850 }
851 }
852 Ok(children)
853 }
854
855 fn open_regular(
859 &self,
860 rel: &str,
861 op: WorkdirOperation,
862 flags: OFlags,
863 mode: u32,
864 create_parents: bool,
865 ) -> Result<File, WorkdirError> {
866 let (parent, name) = self.parent_of(rel, op, create_parents)?;
867 let Some(parent) = parent else {
868 return Err(self.io(rel, std::io::ErrorKind::NotFound.into()));
869 };
870 let flags = flags | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
871 let fd = match rustix::fs::openat(&parent, name, flags, mode_of(mode)) {
872 Ok(fd) => fd,
873 Err(errno) => {
874 return Err(match self.leaf_type(&parent, name, rel)? {
875 Some(FileType::Symlink) => self.refuse(rel, rel, op, RefusalReason::Symlink),
876 Some(FileType::RegularFile) | None => self.io(rel, errno.into()),
877 Some(_) => self.refuse(rel, rel, op, RefusalReason::NotARegularFile),
878 });
879 }
880 };
881 let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
882 if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
883 return Err(self.refuse(rel, rel, op, RefusalReason::NotARegularFile));
884 }
885 if op != WorkdirOperation::Read && stat.st_nlink > 1 {
886 return Err(self.refuse(rel, rel, op, RefusalReason::HardLink));
887 }
888 Ok(File::from(fd))
889 }
890
891 fn segments<'r>(
894 &self,
895 rel: &'r str,
896 op: WorkdirOperation,
897 ) -> Result<Vec<&'r OsStr>, WorkdirError> {
898 let segments: Vec<&OsStr> = rel.split('/').map(OsStr::new).collect();
899 if segments
900 .iter()
901 .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
902 {
903 return Err(self.refuse(rel, rel, op, RefusalReason::InvalidComponent));
904 }
905 Ok(segments)
906 }
907
908 fn parent_of<'r>(
911 &self,
912 rel: &'r str,
913 op: WorkdirOperation,
914 create: bool,
915 ) -> Result<(Option<Held>, &'r OsStr), WorkdirError> {
916 let segments = self.segments(rel, op)?;
917 let name = *segments.last().expect("split yields at least one segment");
918 let held = self.walk(
919 rel,
920 &segments,
921 segments.len() - 1,
922 op,
923 create.then_some(DEFAULT_DIR_MODE),
924 )?;
925 Ok((held, name))
926 }
927
928 fn walk(
931 &self,
932 rel: &str,
933 segments: &[&OsStr],
934 depth: usize,
935 op: WorkdirOperation,
936 create: Option<u32>,
937 ) -> Result<Option<Held>, WorkdirError> {
938 let mut current = Held::Anchor(self.anchor_fd(create.is_some())?);
939 for index in 0..depth {
940 let name = segments[index];
941 let so_far = segments[..=index]
942 .iter()
943 .map(|segment| segment.to_string_lossy())
944 .collect::<Vec<_>>()
945 .join("/");
946 let next = match rustix::fs::openat(¤t, name, DIR_FLAGS, Mode::empty()) {
947 Ok(fd) => fd,
948 Err(Errno::NOENT) => {
949 let Some(mode) = create else {
950 return Ok(None);
951 };
952 match rustix::fs::mkdirat(¤t, name, mode_of(mode)) {
953 Ok(()) | Err(Errno::EXIST) => {}
954 Err(errno) => return Err(self.io(&so_far, errno.into())),
955 }
956 self.open_dir(¤t, name, rel, &so_far, op)?
957 }
958 Err(errno) => return Err(self.classify(¤t, name, rel, &so_far, op, errno)),
959 };
960 current = Held::Opened(next);
961 }
962 Ok(Some(current))
963 }
964
965 fn open_dir(
966 &self,
967 parent: &impl AsFd,
968 name: &OsStr,
969 rel: &str,
970 so_far: &str,
971 op: WorkdirOperation,
972 ) -> Result<OwnedFd, WorkdirError> {
973 rustix::fs::openat(parent, name, DIR_FLAGS, Mode::empty())
974 .map_err(|errno| self.classify(parent, name, rel, so_far, op, errno))
975 }
976
977 fn classify(
980 &self,
981 parent: &impl AsFd,
982 name: &OsStr,
983 rel: &str,
984 so_far: &str,
985 op: WorkdirOperation,
986 errno: Errno,
987 ) -> WorkdirError {
988 match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
989 Ok(stat) if FileType::from_raw_mode(stat.st_mode) == FileType::Symlink => {
990 self.refuse(rel, so_far, op, RefusalReason::Symlink)
991 }
992 _ => self.io(so_far, errno.into()),
993 }
994 }
995
996 fn leaf_type(
998 &self,
999 parent: &impl AsFd,
1000 name: &OsStr,
1001 rel: &str,
1002 ) -> Result<Option<FileType>, WorkdirError> {
1003 match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
1004 Ok(stat) => Ok(Some(FileType::from_raw_mode(stat.st_mode))),
1005 Err(Errno::NOENT) => Ok(None),
1006 Err(errno) => Err(self.io(rel, errno.into())),
1007 }
1008 }
1009
1010 fn anchor_fd(&self, creating: bool) -> Result<Arc<OwnedFd>, WorkdirError> {
1014 let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
1015 let removed = creating
1016 && !nlink_unreliable()
1017 && rustix::fs::fstat(&**held).is_ok_and(|stat| stat.st_nlink == 0);
1018 if removed {
1019 *held = Arc::new(self.reach_again()?);
1020 }
1021 Ok(Arc::clone(&held))
1022 }
1023
1024 fn retry_if_removed<T>(
1032 &self,
1033 call: impl Fn() -> Result<T, WorkdirError>,
1034 ) -> Result<T, WorkdirError> {
1035 match call() {
1036 Err(error) if error.is_not_found() && self.reach_again_if_removed()? => call(),
1037 result => result,
1038 }
1039 }
1040
1041 fn reach_again_if_removed(&self) -> Result<bool, WorkdirError> {
1044 let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
1045 let probe = format!(".{}.probe", uuid::Uuid::now_v7().simple());
1046 match rustix::fs::mkdirat(&**held, probe.as_str(), Mode::RWXU) {
1047 Ok(()) => {
1048 let _ = rustix::fs::unlinkat(&**held, probe.as_str(), AtFlags::REMOVEDIR);
1049 Ok(false)
1050 }
1051 Err(Errno::NOENT) => {
1052 *held = Arc::new(self.reach_again()?);
1053 Ok(true)
1054 }
1055 Err(_) => Ok(false),
1056 }
1057 }
1058
1059 fn reach_again(&self) -> Result<OwnedFd, WorkdirError> {
1062 match &self.anchor.origin {
1063 Origin::Path => {
1064 std::fs::create_dir_all(&self.anchor.path).map_err(|source| self.io("", source))?;
1065 open_by_path(&self.anchor.path)
1066 }
1067 Origin::Beneath { from, rel } => {
1068 let op = WorkdirOperation::CreateDir;
1069 let segments = from.segments(rel, op)?;
1070 let walked = from.retry_if_removed(|| {
1071 from.walk(rel, &segments, segments.len(), op, Some(DEFAULT_DIR_MODE))
1072 })?;
1073 let Some(Held::Opened(fd)) = walked else {
1074 unreachable!("a walk that creates always reaches its last segment")
1075 };
1076 Ok(fd)
1077 }
1078 }
1079 }
1080
1081 fn display(&self, rel: &str) -> String {
1082 match (self.anchor.prefix.is_empty(), rel.is_empty()) {
1083 (true, _) => rel.to_string(),
1084 (false, true) => self.anchor.prefix.clone(),
1085 (false, false) => format!("{}/{rel}", self.anchor.prefix),
1086 }
1087 }
1088
1089 fn refuse(
1090 &self,
1091 rel: &str,
1092 refused_at: &str,
1093 operation: WorkdirOperation,
1094 reason: RefusalReason,
1095 ) -> WorkdirError {
1096 let refusal = WorkdirRefusal {
1097 path: self.display(rel),
1098 refused_at: self.display(refused_at),
1099 operation,
1100 reason,
1101 };
1102 self.latch
1103 .lock()
1104 .unwrap_or_else(|e| e.into_inner())
1105 .push(refusal.clone());
1106 WorkdirError::Refused(refusal)
1107 }
1108
1109 fn io(&self, rel: &str, source: std::io::Error) -> WorkdirError {
1110 WorkdirError::Io {
1111 path: self.display(rel),
1112 source,
1113 }
1114 }
1115}
1116
1117pub(crate) fn fd_identity(fd: impl AsFd) -> rustix::io::Result<(u64, u64)> {
1119 let stat = rustix::fs::fstat(fd)?;
1120 Ok((stat.st_dev as u64, stat.st_ino as u64))
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
1127
1128 struct Fixture {
1129 _temp: tempfile::TempDir,
1130 workdir: PathBuf,
1131 outside: PathBuf,
1132 sentinel: PathBuf,
1133 root: WorkdirRoot,
1134 }
1135
1136 const SENTINEL: &[u8] = b"operator data";
1137
1138 fn refusal_of(error: &WorkdirError) -> &WorkdirRefusal {
1139 match error {
1140 WorkdirError::Refused(refusal) => refusal,
1141 other => panic!("expected a refusal, got {other:?}"),
1142 }
1143 }
1144
1145 fn fixture() -> Fixture {
1146 let temp = tempfile::tempdir().unwrap();
1147 let workdir = temp.path().join("workdir");
1148 let outside = temp.path().join("outside");
1149 std::fs::create_dir(&workdir).unwrap();
1150 std::fs::create_dir(&outside).unwrap();
1151 let sentinel = outside.join("sentinel.txt");
1152 std::fs::write(&sentinel, SENTINEL).unwrap();
1153 let root = WorkdirRoot::open(&workdir).unwrap();
1154 Fixture {
1155 _temp: temp,
1156 workdir,
1157 outside,
1158 sentinel,
1159 root,
1160 }
1161 }
1162
1163 impl Fixture {
1164 fn outside_snapshot(&self) -> Vec<(String, Vec<u8>, i64, i64)> {
1165 let mut entries: Vec<_> = std::fs::read_dir(&self.outside)
1166 .unwrap()
1167 .map(|entry| {
1168 let entry = entry.unwrap();
1169 let meta = std::fs::symlink_metadata(entry.path()).unwrap();
1170 let bytes = if meta.is_file() {
1171 std::fs::read(entry.path()).unwrap()
1172 } else {
1173 Vec::new()
1174 };
1175 (
1176 entry.file_name().to_string_lossy().into_owned(),
1177 bytes,
1178 meta.mtime(),
1179 meta.mtime_nsec(),
1180 )
1181 })
1182 .collect();
1183 entries.sort();
1184 entries
1185 }
1186
1187 fn link_at_target(&self, rel: &str) {
1189 let host = self.workdir.join(rel);
1190 std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1191 symlink(&self.sentinel, host).unwrap();
1192 }
1193
1194 fn dangling_at_target(&self, rel: &str) {
1195 let host = self.workdir.join(rel);
1196 std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1197 symlink(self.outside.join("not-yet.txt"), host).unwrap();
1198 }
1199
1200 fn link_at_parent(&self, parent_rel: &str) {
1201 let host = self.workdir.join(parent_rel);
1202 std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1203 symlink(&self.outside, host).unwrap();
1204 }
1205 }
1206
1207 type Operation = fn(&WorkdirRoot, &str) -> Result<(), WorkdirError>;
1208
1209 fn operations() -> Vec<(&'static str, WorkdirOperation, Operation)> {
1210 vec![
1211 ("write_file", WorkdirOperation::Write, |root, rel| {
1212 root.write_file(rel, b"runtime bytes", 0o644)
1213 }),
1214 ("open_append", WorkdirOperation::Append, |root, rel| {
1215 root.open_append(rel, 0o644).and_then(|mut file| {
1216 file.write_all(b"runtime bytes")
1217 .map_err(|source| WorkdirError::Io {
1218 path: rel.to_string(),
1219 source,
1220 })
1221 })
1222 }),
1223 ("read_file", WorkdirOperation::Read, |root, rel| {
1224 root.read_file(rel, u64::MAX).map(|_| ())
1225 }),
1226 (
1227 "create_dir_all",
1228 WorkdirOperation::CreateDir,
1229 |root, rel| root.create_dir_all(rel, 0o755),
1230 ),
1231 ("remove_file", WorkdirOperation::Remove, |root, rel| {
1232 root.remove_file(rel)
1233 }),
1234 ("remove_dir_all", WorkdirOperation::Remove, |root, rel| {
1235 root.remove_dir_all(rel)
1236 }),
1237 ]
1238 }
1239
1240 fn assert_refused(
1241 fixture: &Fixture,
1242 result: Result<(), WorkdirError>,
1243 before: &[(String, Vec<u8>, i64, i64)],
1244 label: &str,
1245 path: &str,
1246 refused_at: &str,
1247 operation: WorkdirOperation,
1248 ) {
1249 let expected = WorkdirRefusal {
1250 path: path.to_string(),
1251 refused_at: refused_at.to_string(),
1252 operation,
1253 reason: RefusalReason::Symlink,
1254 };
1255 match result {
1256 Err(WorkdirError::Refused(refusal)) => assert_eq!(refusal, expected, "{label}"),
1257 other => panic!("{label}: expected a refusal, got {other:?}"),
1258 }
1259 assert_eq!(
1260 fixture.root.take_refusals(),
1261 vec![expected],
1262 "{label}: latched once"
1263 );
1264 assert_eq!(
1265 fixture.outside_snapshot(),
1266 before,
1267 "{label}: outside changed"
1268 );
1269 assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1270 }
1271
1272 #[test]
1273 fn every_operation_refuses_a_link_at_the_target() {
1274 for (label, operation, call) in operations() {
1275 let fixture = fixture();
1276 fixture.link_at_target("dir/target");
1277 let before = fixture.outside_snapshot();
1278 let result = call(&fixture.root, "dir/target");
1279 assert_refused(
1280 &fixture,
1281 result,
1282 &before,
1283 label,
1284 "dir/target",
1285 "dir/target",
1286 operation,
1287 );
1288 assert!(
1289 std::fs::symlink_metadata(fixture.workdir.join("dir/target"))
1290 .unwrap()
1291 .file_type()
1292 .is_symlink()
1293 );
1294 }
1295 }
1296
1297 #[test]
1298 fn every_operation_refuses_a_link_at_a_parent_directory() {
1299 for (label, operation, call) in operations() {
1300 let fixture = fixture();
1301 std::fs::create_dir(fixture.outside.join("dir")).unwrap();
1302 std::fs::write(fixture.outside.join("dir/target"), SENTINEL).unwrap();
1303 fixture.link_at_parent("a/b");
1304 let before = fixture.outside_snapshot();
1305 let result = call(&fixture.root, "a/b/dir/target");
1306 assert_refused(
1307 &fixture,
1308 result,
1309 &before,
1310 label,
1311 "a/b/dir/target",
1312 "a/b",
1313 operation,
1314 );
1315 assert_eq!(
1316 std::fs::read(fixture.outside.join("dir/target")).unwrap(),
1317 SENTINEL
1318 );
1319 }
1320 }
1321
1322 #[test]
1323 fn every_operation_refuses_a_dangling_link_at_the_target() {
1324 for (label, operation, call) in operations() {
1325 let fixture = fixture();
1326 fixture.dangling_at_target("target");
1327 let before = fixture.outside_snapshot();
1328 let result = call(&fixture.root, "target");
1329 assert_refused(
1330 &fixture, result, &before, label, "target", "target", operation,
1331 );
1332 assert!(!fixture.outside.join("not-yet.txt").exists(), "{label}");
1333 }
1334 }
1335
1336 #[test]
1337 fn a_non_plain_component_is_refused_before_any_syscall() {
1338 let fixture = fixture();
1339 for rel in [
1340 "",
1341 "/etc/passwd",
1342 "../outside/sentinel.txt",
1343 "a/./b",
1344 "a//b",
1345 "a/",
1346 ".",
1347 ] {
1348 for (label, operation, call) in operations() {
1349 match call(&fixture.root, rel) {
1350 Err(WorkdirError::Refused(refusal)) => {
1351 assert_eq!(
1352 refusal.reason,
1353 RefusalReason::InvalidComponent,
1354 "{label} {rel}"
1355 );
1356 assert_eq!(refusal.operation, operation);
1357 assert_eq!(refusal.path, rel);
1358 }
1359 other => panic!("{label} {rel:?}: expected a refusal, got {other:?}"),
1360 }
1361 assert_eq!(fixture.root.take_refusals().len(), 1);
1362 }
1363 }
1364 assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1365 assert_eq!(std::fs::read_dir(&fixture.workdir).unwrap().count(), 0);
1366 }
1367
1368 #[test]
1369 fn a_fifo_is_refused_for_append_and_read_without_blocking() {
1370 let fixture = fixture();
1371 let fifo = fixture.workdir.join("pipe");
1372 assert!(std::process::Command::new("mkfifo")
1373 .arg(&fifo)
1374 .status()
1375 .unwrap()
1376 .success());
1377 let append = fixture.root.open_append("pipe", 0o644).map(|_| ());
1378 let read = fixture.root.read_file("pipe", u64::MAX).map(|_| ());
1379 for (result, operation) in [
1380 (append, WorkdirOperation::Append),
1381 (read, WorkdirOperation::Read),
1382 ] {
1383 match result {
1384 Err(WorkdirError::Refused(refusal)) => {
1385 assert_eq!(refusal.reason, RefusalReason::NotARegularFile);
1386 assert_eq!(refusal.operation, operation);
1387 assert_eq!(refusal.refused_at, "pipe");
1388 }
1389 other => panic!("expected a refusal, got {other:?}"),
1390 }
1391 }
1392 assert_eq!(fixture.root.take_refusals().len(), 2);
1393 }
1394
1395 #[test]
1396 fn an_append_to_a_hard_link_into_outside_is_refused() {
1397 let fixture = fixture();
1398 std::fs::hard_link(&fixture.sentinel, fixture.workdir.join("log")).unwrap();
1399 let before = fixture.outside_snapshot();
1400 match fixture.root.open_append("log", 0o644) {
1401 Err(WorkdirError::Refused(refusal)) => {
1402 assert_eq!(refusal.reason, RefusalReason::HardLink);
1403 assert_eq!(refusal.operation, WorkdirOperation::Append);
1404 assert_eq!(refusal.path, "log");
1405 }
1406 other => panic!("expected a refusal, got {other:?}"),
1407 }
1408 assert_eq!(fixture.outside_snapshot(), before);
1409 assert_eq!(fixture.root.take_refusals().len(), 1);
1410 }
1411
1412 #[test]
1413 fn a_write_replaces_an_existing_file_and_leaves_no_temporary_sibling() {
1414 let fixture = fixture();
1415 std::fs::create_dir(fixture.workdir.join("out")).unwrap();
1416 std::fs::write(fixture.workdir.join("out/result.txt"), b"old").unwrap();
1417 fixture
1418 .root
1419 .write_file("out/result.txt", b"new", 0o600)
1420 .unwrap();
1421 assert_eq!(
1422 std::fs::read(fixture.workdir.join("out/result.txt")).unwrap(),
1423 b"new"
1424 );
1425 let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("out"))
1426 .unwrap()
1427 .map(|entry| entry.unwrap().file_name())
1428 .collect();
1429 assert_eq!(names, vec![std::ffi::OsString::from("result.txt")]);
1430 let mode = std::fs::metadata(fixture.workdir.join("out/result.txt"))
1431 .unwrap()
1432 .permissions()
1433 .mode();
1434 assert_eq!(mode & 0o777, 0o600);
1435 assert!(fixture.root.take_refusals().is_empty());
1436 }
1437
1438 #[test]
1439 fn a_link_planted_between_the_check_and_the_rename_is_replaced_not_followed() {
1440 let fixture = fixture();
1441 let target = fixture.workdir.join("task.md");
1442 let sentinel = fixture.sentinel.clone();
1443 let planted = target.clone();
1444 before_next_rename(move || symlink(&sentinel, &planted).unwrap());
1445 let before = fixture.outside_snapshot();
1446 fixture
1447 .root
1448 .write_file("task.md", b"the task", 0o644)
1449 .unwrap();
1450 assert_eq!(fixture.outside_snapshot(), before);
1451 let meta = std::fs::symlink_metadata(&target).unwrap();
1452 assert!(meta.is_file(), "the rename replaced the planted link");
1453 assert_eq!(std::fs::read(&target).unwrap(), b"the task");
1454 }
1455
1456 fn held_beneath(root: &WorkdirRoot) -> Result<WorkdirRoot, WorkdirError> {
1458 root.create_dir_all("records/ses_test", DEFAULT_DIR_MODE)?;
1459 Ok(root
1460 .open_beneath("records/ses_test")?
1461 .expect("the directory was just created"))
1462 }
1463
1464 #[test]
1465 fn a_link_planted_after_a_root_beneath_is_held_redirects_nothing() {
1466 let fixture = fixture();
1467 let session = held_beneath(&fixture.root).unwrap();
1468 let murmur = fixture.workdir.join("records");
1469 std::fs::rename(&murmur, fixture.workdir.join("moved")).unwrap();
1470 symlink(&fixture.outside, &murmur).unwrap();
1471 let before = fixture.outside_snapshot();
1472 session
1473 .write_file("plans/plan-1.json", b"{}", 0o644)
1474 .unwrap();
1475 assert_eq!(fixture.outside_snapshot(), before);
1476 assert_eq!(
1477 std::fs::read(fixture.workdir.join("moved/ses_test/plans/plan-1.json")).unwrap(),
1478 b"{}"
1479 );
1480 }
1481
1482 #[test]
1483 fn a_refusal_beneath_a_held_root_names_the_path_from_its_parent_and_shares_the_latch() {
1484 let fixture = fixture();
1485 let session = held_beneath(&fixture.root).unwrap();
1486 symlink(
1487 &fixture.outside,
1488 fixture.workdir.join("records/ses_test/plans"),
1489 )
1490 .unwrap();
1491 let error = session
1492 .write_file("plans/plan-1.json", b"{}", 0o644)
1493 .unwrap_err();
1494 let refusal = refusal_of(&error);
1495 assert_eq!(refusal.path, "records/ses_test/plans/plan-1.json");
1496 assert_eq!(refusal.refused_at, "records/ses_test/plans");
1497 assert_eq!(
1498 error.to_string(),
1499 "refused to write records/ses_test/plans/plan-1.json: records/ses_test/plans is a \
1500 symbolic link inside the workdir"
1501 );
1502 assert_eq!(fixture.root.take_refusals(), vec![refusal.clone()]);
1503 assert!(session.take_refusals().is_empty());
1504 }
1505
1506 #[test]
1507 fn a_link_at_derivation_of_a_root_beneath_is_refused() {
1508 let fixture = fixture();
1509 symlink(&fixture.outside, fixture.workdir.join("records")).unwrap();
1510 let error = held_beneath(&fixture.root).unwrap_err();
1511 let refusal = refusal_of(&error);
1512 assert_eq!(refusal.refused_at, "records");
1513 assert_eq!(refusal.operation, WorkdirOperation::CreateDir);
1514 assert_eq!(std::fs::read_dir(&fixture.outside).unwrap().count(), 1);
1515 }
1516
1517 #[test]
1518 fn a_removed_directory_beneath_is_walked_to_again_before_a_write() {
1519 for nlink_unreliable in [false, true] {
1520 NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1521 let fixture = fixture();
1522 let session = held_beneath(&fixture.root).unwrap();
1523 std::fs::remove_dir_all(fixture.workdir.join("records")).unwrap();
1524
1525 session
1526 .write_file("out/result.txt", b"answer", 0o644)
1527 .unwrap();
1528 session
1529 .open_append("logs/bootstrap.log", 0o644)
1530 .unwrap()
1531 .write_all(b"line\n")
1532 .unwrap();
1533
1534 assert_eq!(
1535 std::fs::read(fixture.workdir.join("records/ses_test/out/result.txt")).unwrap(),
1536 b"answer"
1537 );
1538 assert!(fixture
1539 .workdir
1540 .join("records/ses_test/logs/bootstrap.log")
1541 .is_file());
1542 assert!(fixture.root.take_refusals().is_empty());
1543 }
1544 NLINK_UNRELIABLE.with(|cell| cell.set(false));
1545 }
1546
1547 #[test]
1550 fn a_moved_directory_beneath_is_still_the_one_written_to() {
1551 NLINK_UNRELIABLE.with(|cell| cell.set(true));
1552 let fixture = fixture();
1553 let session = held_beneath(&fixture.root).unwrap();
1554 std::fs::rename(
1555 fixture.workdir.join("records"),
1556 fixture.workdir.join("moved"),
1557 )
1558 .unwrap();
1559
1560 session
1561 .write_file("out/result.txt", b"answer", 0o644)
1562 .unwrap();
1563
1564 NLINK_UNRELIABLE.with(|cell| cell.set(false));
1565 assert_eq!(
1566 std::fs::read(fixture.workdir.join("moved/ses_test/out/result.txt")).unwrap(),
1567 b"answer"
1568 );
1569 assert!(!fixture.workdir.join("records").exists());
1570 let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("moved/ses_test"))
1571 .unwrap()
1572 .map(|entry| entry.unwrap().file_name())
1573 .collect();
1574 assert_eq!(names, ["out"]);
1575 }
1576
1577 #[test]
1578 fn a_removed_directory_beneath_replaced_by_a_link_is_refused() {
1579 for nlink_unreliable in [false, true] {
1580 NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1581 let fixture = fixture();
1582 let session = held_beneath(&fixture.root).unwrap();
1583 std::fs::remove_dir_all(fixture.workdir.join("records")).unwrap();
1584 std::os::unix::fs::symlink(&fixture.outside, fixture.workdir.join("records")).unwrap();
1585 let before = fixture.outside_snapshot();
1586
1587 let error = session
1588 .write_file("out/result.txt", b"answer", 0o644)
1589 .unwrap_err();
1590
1591 assert_eq!(refusal_of(&error).refused_at, "records");
1592 assert_eq!(fixture.outside_snapshot(), before);
1593 }
1594 NLINK_UNRELIABLE.with(|cell| cell.set(false));
1595 }
1596
1597 #[test]
1598 fn absent_paths_read_as_none_with_no_refusal() {
1599 let fixture = fixture();
1600 assert_eq!(fixture.root.read_file("task.md", 10).unwrap(), None);
1601 assert_eq!(fixture.root.read_to_string("a/b/c").unwrap(), None);
1602 assert_eq!(fixture.root.entry_kind("a/b").unwrap(), None);
1603 assert_eq!(fixture.root.list_dir("tools").unwrap(), None);
1604 assert_eq!(fixture.root.file_len("logs/x.log").unwrap(), None);
1605 assert!(fixture.root.take_refusals().is_empty());
1606 }
1607
1608 #[test]
1609 fn create_dir_all_applies_the_mode_to_what_it_creates() {
1610 let fixture = fixture();
1611 fixture
1612 .root
1613 .create_dir_all(".process-driver/run", 0o700)
1614 .unwrap();
1615 let mode = std::fs::metadata(fixture.workdir.join(".process-driver/run"))
1616 .unwrap()
1617 .permissions()
1618 .mode();
1619 assert_eq!(mode & 0o777, 0o700);
1620 assert_eq!(
1621 fixture.root.entry_kind(".process-driver").unwrap(),
1622 Some(EntryKind::Directory)
1623 );
1624 }
1625
1626 #[test]
1627 fn remove_dir_all_unlinks_a_link_inside_without_following_it() {
1628 let fixture = fixture();
1629 std::fs::create_dir_all(fixture.workdir.join("tools/x/sub")).unwrap();
1630 std::fs::write(fixture.workdir.join("tools/x/sub/file"), b"x").unwrap();
1631 symlink(&fixture.outside, fixture.workdir.join("tools/x/escape")).unwrap();
1632 let before = fixture.outside_snapshot();
1633 fixture.root.remove_dir_all("tools/x").unwrap();
1634 assert!(!fixture.workdir.join("tools/x").exists());
1635 assert_eq!(fixture.outside_snapshot(), before);
1636 }
1637
1638 #[test]
1639 fn overwrite_in_place_keeps_the_inode_and_refuses_a_link() {
1640 let fixture = fixture();
1641 fixture
1642 .root
1643 .overwrite_in_place(".mur-etc/passwd", b"one\n", 0o644)
1644 .unwrap();
1645 let inode = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd"))
1646 .unwrap()
1647 .ino();
1648 fixture
1649 .root
1650 .overwrite_in_place(".mur-etc/passwd", b"two\n", 0o644)
1651 .unwrap();
1652 let meta = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd")).unwrap();
1653 assert_eq!(meta.ino(), inode);
1654 assert_eq!(
1655 std::fs::read(fixture.workdir.join(".mur-etc/passwd")).unwrap(),
1656 b"two\n"
1657 );
1658
1659 fixture.link_at_target(".mur-etc/group");
1660 let error = fixture
1661 .root
1662 .overwrite_in_place(".mur-etc/group", b"x", 0o644)
1663 .unwrap_err();
1664 assert_eq!(refusal_of(&error).reason, RefusalReason::Symlink);
1665 assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1666 }
1667
1668 #[test]
1669 fn list_dir_reports_a_link_inside_as_a_symlink() {
1670 let fixture = fixture();
1671 std::fs::create_dir_all(fixture.workdir.join("tools/a")).unwrap();
1672 symlink(&fixture.outside, fixture.workdir.join("tools/b")).unwrap();
1673 assert_eq!(
1674 fixture.root.list_dir("tools").unwrap().unwrap(),
1675 vec![
1676 ("a".to_string(), EntryKind::Directory),
1677 ("b".to_string(), EntryKind::Symlink)
1678 ]
1679 );
1680 }
1681
1682 #[test]
1683 fn a_refusal_serializes_with_the_wire_names() {
1684 let refusal = WorkdirRefusal {
1685 path: "task.md".into(),
1686 refused_at: "task.md".into(),
1687 operation: WorkdirOperation::CreateDir,
1688 reason: RefusalReason::NotARegularFile,
1689 };
1690 assert_eq!(
1691 serde_json::to_string(&refusal).unwrap(),
1692 r#"{"path":"task.md","refused_at":"task.md","operation":"create_dir","reason":"not_a_regular_file"}"#
1693 );
1694 for (op, wire) in [
1695 (WorkdirOperation::Write, "write"),
1696 (WorkdirOperation::Append, "append"),
1697 (WorkdirOperation::Read, "read"),
1698 (WorkdirOperation::CreateDir, "create_dir"),
1699 (WorkdirOperation::Remove, "remove"),
1700 ] {
1701 assert_eq!(serde_json::to_value(op).unwrap(), serde_json::json!(wire));
1702 }
1703 for (reason, wire) in [
1704 (RefusalReason::Symlink, "symlink"),
1705 (RefusalReason::HardLink, "hard_link"),
1706 (RefusalReason::NotARegularFile, "not_a_regular_file"),
1707 (RefusalReason::InvalidComponent, "invalid_component"),
1708 ] {
1709 assert_eq!(
1710 serde_json::to_value(reason).unwrap(),
1711 serde_json::json!(wire)
1712 );
1713 }
1714 }
1715}