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 session_beneath(&self, session_id: &str) -> Result<Self, WorkdirError> {
317 let rel = format!(".murmur/{session_id}");
318 let segments = self.segments(&rel, WorkdirOperation::CreateDir)?;
319 let held = self.retry_if_removed(|| {
320 self.walk(
321 &rel,
322 &segments,
323 segments.len(),
324 WorkdirOperation::CreateDir,
325 Some(DEFAULT_DIR_MODE),
326 )
327 })?;
328 let Some(Held::Opened(fd)) = held else {
329 unreachable!("a walk that creates always reaches its last segment")
330 };
331 Ok(Self {
332 anchor: Arc::new(Anchor {
333 fd: Mutex::new(Arc::new(fd)),
334 path: self.anchor.path.join(".murmur").join(session_id),
335 prefix: self.display(&rel),
336 origin: Origin::Beneath {
337 from: self.clone(),
338 rel,
339 },
340 }),
341 latch: Arc::clone(&self.latch),
342 })
343 }
344
345 pub fn open_beneath(&self, rel: &str) -> Result<Option<Self>, WorkdirError> {
348 let op = WorkdirOperation::Read;
349 let segments = self.segments(rel, op)?;
350 let Some(held) = self.walk(rel, &segments, segments.len(), op, None)? else {
351 return Ok(None);
352 };
353 let Held::Opened(fd) = held else {
354 unreachable!("a walk over at least one segment opens a descriptor")
355 };
356 Ok(Some(Self {
357 anchor: Arc::new(Anchor {
358 fd: Mutex::new(Arc::new(fd)),
359 path: self.anchor.path.join(rel),
360 prefix: self.display(rel),
361 origin: Origin::Beneath {
362 from: self.clone(),
363 rel: rel.to_string(),
364 },
365 }),
366 latch: Arc::clone(&self.latch),
367 }))
368 }
369
370 pub fn open_sharing_latch(path: &Path, latch_of: &WorkdirRoot) -> Result<Self, WorkdirError> {
374 let opened = Self::open(path)?;
375 Ok(Self {
376 anchor: opened.anchor,
377 latch: Arc::clone(&latch_of.latch),
378 })
379 }
380
381 pub fn open_sharing_latch_absolute(
385 path: &Path,
386 latch_of: &WorkdirRoot,
387 ) -> Result<Self, WorkdirError> {
388 let opened = Self::open(path)?;
389 Ok(Self {
390 anchor: Arc::new(Anchor {
391 fd: Mutex::new(opened.anchor_fd(false)?),
392 path: path.to_path_buf(),
393 prefix: path.display().to_string(),
394 origin: Origin::Path,
395 }),
396 latch: Arc::clone(&latch_of.latch),
397 })
398 }
399
400 pub fn identity(&self) -> Result<(u64, u64), WorkdirError> {
402 fd_identity(&*self.anchor_fd(false)?).map_err(|errno| self.io("", errno.into()))
403 }
404
405 pub fn dir_fd(&self) -> Result<OwnedFd, WorkdirError> {
408 let anchor = self.anchor_fd(false)?;
409 rustix::fs::openat(&*anchor, ".", DIR_FLAGS, Mode::empty())
410 .map_err(|errno| self.io("", errno.into()))
411 }
412
413 #[cfg(target_os = "linux")]
417 pub fn path_fd(&self) -> Result<OwnedFd, WorkdirError> {
418 let anchor = self.anchor_fd(false)?;
419 rustix::fs::openat(
420 &*anchor,
421 ".",
422 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
423 Mode::empty(),
424 )
425 .map_err(|errno| self.io("", errno.into()))
426 }
427
428 #[cfg(target_os = "linux")]
432 pub fn path_fd_beneath(&self, rel: impl AsRef<Path>) -> Result<OwnedFd, WorkdirError> {
433 let rel = rel.as_ref().to_string_lossy();
434 let op = WorkdirOperation::Read;
435 let (parent, name) = self.parent_of(&rel, op, false)?;
436 let Some(parent) = parent else {
437 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
438 };
439 let flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
440 let refused_or = |errno: Errno| match self.leaf_type(&parent, name, &rel) {
441 Ok(Some(FileType::Symlink)) => self.refuse(&rel, &rel, op, RefusalReason::Symlink),
442 Ok(_) => self.io(&rel, errno.into()),
443 Err(error) => error,
444 };
445 let fd = rustix::fs::openat(&parent, name, flags, Mode::empty()).map_err(refused_or)?;
446 let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(&rel, errno.into()))?;
449 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory {
450 return Err(refused_or(Errno::NOTDIR));
451 }
452 Ok(fd)
453 }
454
455 pub fn path(&self) -> &Path {
458 &self.anchor.path
459 }
460
461 pub fn host_path(&self, rel: impl AsRef<Path>) -> PathBuf {
463 self.anchor.path.join(rel)
464 }
465
466 pub fn display_path(&self, rel: impl AsRef<Path>) -> String {
468 self.display(&rel.as_ref().to_string_lossy())
469 }
470
471 pub fn owner(&self) -> Result<(u32, u32), WorkdirError> {
473 let stat = rustix::fs::fstat(&*self.anchor_fd(false)?)
474 .map_err(|errno| self.io("", errno.into()))?;
475 Ok((stat.st_uid, stat.st_gid))
476 }
477
478 pub fn take_refusals(&self) -> Vec<WorkdirRefusal> {
480 std::mem::take(&mut *self.latch.lock().unwrap_or_else(|e| e.into_inner()))
481 }
482
483 pub fn write_file(
490 &self,
491 rel: impl AsRef<Path>,
492 bytes: &[u8],
493 mode: u32,
494 ) -> Result<(), WorkdirError> {
495 self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, false))
496 }
497
498 fn write_file_inner(
499 &self,
500 rel: &Path,
501 bytes: &[u8],
502 mode: u32,
503 exact_mode: bool,
504 ) -> Result<(), WorkdirError> {
505 let rel = rel.to_string_lossy();
506 let op = WorkdirOperation::Write;
507 let (parent, name) = self.parent_of(&rel, op, true)?;
508 let parent = parent.expect("a creating walk reaches the parent");
509 match self.leaf_type(&parent, name, &rel)? {
510 Some(FileType::Symlink) => {
511 return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
512 }
513 Some(FileType::Directory) => {
514 return Err(self.io(&rel, std::io::ErrorKind::IsADirectory.into()));
515 }
516 _ => {}
517 }
518 let temp = format!(
519 ".{}.{}.tmp",
520 name.to_string_lossy(),
521 uuid::Uuid::now_v7().simple()
522 );
523 let flags =
524 OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
525 let fd = rustix::fs::openat(&parent, temp.as_str(), flags, mode_of(mode))
526 .map_err(|errno| self.io(&rel, errno.into()))?;
527 let chmod = if exact_mode {
528 rustix::fs::fchmod(&fd, mode_of(mode)).map_err(|errno| self.io(&rel, errno.into()))
529 } else {
530 Ok(())
531 };
532 let result = chmod
533 .and_then(|()| {
534 File::from(fd)
535 .write_all(bytes)
536 .map_err(|source| self.io(&rel, source))
537 })
538 .and_then(|()| {
539 #[cfg(test)]
540 if let Some(hook) = BEFORE_RENAME.with(|slot| slot.borrow_mut().take()) {
541 hook();
542 }
543 rustix::fs::renameat(&parent, temp.as_str(), &parent, name)
544 .map_err(|errno| self.io(&rel, errno.into()))
545 });
546 if result.is_err() {
547 let _ = rustix::fs::unlinkat(&parent, temp.as_str(), AtFlags::empty());
548 }
549 result
550 }
551
552 pub fn write_file_exact_mode(
555 &self,
556 rel: impl AsRef<Path>,
557 bytes: &[u8],
558 mode: u32,
559 ) -> Result<(), WorkdirError> {
560 self.retry_if_removed(|| self.write_file_inner(rel.as_ref(), bytes, mode, true))
561 }
562
563 pub fn overwrite_in_place(
568 &self,
569 rel: impl AsRef<Path>,
570 bytes: &[u8],
571 mode: u32,
572 ) -> Result<(), WorkdirError> {
573 let rel = rel.as_ref().to_string_lossy();
574 let op = WorkdirOperation::Write;
575 let flags = OFlags::WRONLY | OFlags::CREATE;
576 let mut file = self.retry_if_removed(|| self.open_regular(&rel, op, flags, mode, true))?;
577 file.set_len(0).map_err(|source| self.io(&rel, source))?;
578 file.write_all(bytes)
579 .map_err(|source| self.io(&rel, source))
580 }
581
582 pub fn open_append(&self, rel: impl AsRef<Path>, mode: u32) -> Result<File, WorkdirError> {
588 let rel = rel.as_ref().to_string_lossy();
589 let flags = OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE;
590 self.retry_if_removed(|| {
591 self.open_regular(&rel, WorkdirOperation::Append, flags, mode, true)
592 })
593 }
594
595 pub fn read_file(
600 &self,
601 rel: impl AsRef<Path>,
602 max_bytes: u64,
603 ) -> Result<Option<Vec<u8>>, WorkdirError> {
604 let rel = rel.as_ref().to_string_lossy();
605 let mut file =
606 match self.open_regular(&rel, WorkdirOperation::Read, OFlags::RDONLY, 0, false) {
607 Ok(file) => file,
608 Err(error) if error.is_not_found() => return Ok(None),
609 Err(error) => return Err(error),
610 };
611 let mut bytes = Vec::new();
612 Read::by_ref(&mut file)
613 .take(max_bytes.saturating_add(1))
614 .read_to_end(&mut bytes)
615 .map_err(|source| self.io(&rel, source))?;
616 if bytes.len() as u64 > max_bytes {
617 return Err(self.io(
618 &rel,
619 std::io::Error::new(
620 std::io::ErrorKind::InvalidData,
621 format!("larger than {max_bytes} bytes"),
622 ),
623 ));
624 }
625 Ok(Some(bytes))
626 }
627
628 pub fn read_to_string(&self, rel: impl AsRef<Path>) -> Result<Option<String>, WorkdirError> {
630 let rel = rel.as_ref();
631 let Some(bytes) = self.read_file(rel, u64::MAX)? else {
632 return Ok(None);
633 };
634 String::from_utf8(bytes).map(Some).map_err(|error| {
635 self.io(
636 &rel.to_string_lossy(),
637 std::io::Error::new(std::io::ErrorKind::InvalidData, error),
638 )
639 })
640 }
641
642 pub fn create_dir_all(&self, rel: impl AsRef<Path>, mode: u32) -> Result<(), WorkdirError> {
645 let rel = rel.as_ref().to_string_lossy();
646 let op = WorkdirOperation::CreateDir;
647 let segments = self.segments(&rel, op)?;
648 self.retry_if_removed(|| self.walk(&rel, &segments, segments.len(), op, Some(mode)))
649 .map(|_| ())
650 }
651
652 pub fn entry_kind(&self, rel: impl AsRef<Path>) -> Result<Option<EntryKind>, WorkdirError> {
656 let rel = rel.as_ref().to_string_lossy();
657 let op = WorkdirOperation::Read;
658 let (parent, name) = self.parent_of(&rel, op, false)?;
659 let Some(parent) = parent else {
660 return Ok(None);
661 };
662 Ok(self.leaf_type(&parent, name, &rel)?.map(EntryKind::of))
663 }
664
665 pub fn file_len(&self, rel: impl AsRef<Path>) -> Result<Option<u64>, WorkdirError> {
668 let rel = rel.as_ref().to_string_lossy();
669 let op = WorkdirOperation::Read;
670 let (parent, name) = self.parent_of(&rel, op, false)?;
671 let Some(parent) = parent else {
672 return Ok(None);
673 };
674 match rustix::fs::statat(&parent, name, AtFlags::SYMLINK_NOFOLLOW) {
675 Ok(stat) => match FileType::from_raw_mode(stat.st_mode) {
676 FileType::RegularFile => Ok(Some(stat.st_size as u64)),
677 FileType::Symlink => Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink)),
678 _ => Ok(None),
679 },
680 Err(Errno::NOENT) => Ok(None),
681 Err(errno) => Err(self.io(&rel, errno.into())),
682 }
683 }
684
685 pub fn list_dir(
689 &self,
690 rel: impl AsRef<Path>,
691 ) -> Result<Option<Vec<(String, EntryKind)>>, WorkdirError> {
692 let rel = rel.as_ref().to_string_lossy();
693 let op = WorkdirOperation::Read;
694 let held = if rel.is_empty() {
695 Some(Held::Anchor(self.anchor_fd(false)?))
696 } else {
697 let segments = self.segments(&rel, op)?;
698 self.walk(&rel, &segments, segments.len(), op, None)?
699 };
700 let Some(dir) = held else {
701 return Ok(None);
702 };
703 let mut entries = Vec::new();
704 for (name, file_type) in self.children(&dir, &rel)? {
705 entries.push((
706 name.to_string_lossy().into_owned(),
707 EntryKind::of(file_type),
708 ));
709 }
710 entries.sort();
711 Ok(Some(entries))
712 }
713
714 pub fn remove_file(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
717 let rel = rel.as_ref().to_string_lossy();
718 let op = WorkdirOperation::Remove;
719 let (parent, name) = self.parent_of(&rel, op, false)?;
720 let Some(parent) = parent else {
721 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
722 };
723 if self.leaf_type(&parent, name, &rel)? == Some(FileType::Symlink) {
724 return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
725 }
726 rustix::fs::unlinkat(&parent, name, AtFlags::empty())
727 .map_err(|errno| self.io(&rel, errno.into()))
728 }
729
730 pub fn remove_dir_all(&self, rel: impl AsRef<Path>) -> Result<(), WorkdirError> {
735 let rel = rel.as_ref().to_string_lossy();
736 let op = WorkdirOperation::Remove;
737 let (parent, name) = self.parent_of(&rel, op, false)?;
738 let Some(parent) = parent else {
739 return Err(self.io(&rel, std::io::ErrorKind::NotFound.into()));
740 };
741 match self.leaf_type(&parent, name, &rel)? {
742 None => return Err(self.io(&rel, std::io::ErrorKind::NotFound.into())),
743 Some(FileType::Symlink) => {
744 return Err(self.refuse(&rel, &rel, op, RefusalReason::Symlink));
745 }
746 Some(FileType::Directory) => {}
747 Some(_) => return Err(self.io(&rel, std::io::ErrorKind::NotADirectory.into())),
748 }
749 let dir = self.open_dir(&parent, name, &rel, &rel, op)?;
750 self.empty_dir(&dir, &rel)?;
751 rustix::fs::unlinkat(&parent, name, AtFlags::REMOVEDIR)
752 .map_err(|errno| self.io(&rel, errno.into()))
753 }
754
755 fn empty_dir(&self, dir: &OwnedFd, rel: &str) -> Result<(), WorkdirError> {
756 for (name, file_type) in self.children(dir, rel)? {
757 let child = format!("{rel}/{}", name.to_string_lossy());
758 if file_type == FileType::Directory {
759 let sub = self.open_dir(dir, &name, &child, &child, WorkdirOperation::Remove)?;
760 self.empty_dir(&sub, &child)?;
761 rustix::fs::unlinkat(dir, &name, AtFlags::REMOVEDIR)
762 } else {
763 rustix::fs::unlinkat(dir, &name, AtFlags::empty())
764 }
765 .map_err(|errno| self.io(&child, errno.into()))?;
766 }
767 Ok(())
768 }
769
770 fn children(
773 &self,
774 dir: &impl AsFd,
775 rel: &str,
776 ) -> Result<Vec<(std::ffi::OsString, FileType)>, WorkdirError> {
777 let mut listing =
778 rustix::fs::Dir::read_from(dir).map_err(|errno| self.io(rel, errno.into()))?;
779 let mut names = Vec::new();
780 while let Some(entry) = listing.read() {
781 let entry = entry.map_err(|errno| self.io(rel, errno.into()))?;
782 let name = OsStr::from_bytes(entry.file_name().to_bytes());
783 if name == "." || name == ".." {
784 continue;
785 }
786 names.push(name.to_os_string());
787 }
788 let mut children = Vec::with_capacity(names.len());
789 for name in names {
790 match rustix::fs::statat(dir, &name, AtFlags::SYMLINK_NOFOLLOW) {
791 Ok(stat) => children.push((name, FileType::from_raw_mode(stat.st_mode))),
792 Err(Errno::NOENT) => {}
793 Err(errno) => return Err(self.io(rel, errno.into())),
794 }
795 }
796 Ok(children)
797 }
798
799 fn open_regular(
803 &self,
804 rel: &str,
805 op: WorkdirOperation,
806 flags: OFlags,
807 mode: u32,
808 create_parents: bool,
809 ) -> Result<File, WorkdirError> {
810 let (parent, name) = self.parent_of(rel, op, create_parents)?;
811 let Some(parent) = parent else {
812 return Err(self.io(rel, std::io::ErrorKind::NotFound.into()));
813 };
814 let flags = flags | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
815 let fd = match rustix::fs::openat(&parent, name, flags, mode_of(mode)) {
816 Ok(fd) => fd,
817 Err(errno) => {
818 return Err(match self.leaf_type(&parent, name, rel)? {
819 Some(FileType::Symlink) => self.refuse(rel, rel, op, RefusalReason::Symlink),
820 Some(FileType::RegularFile) | None => self.io(rel, errno.into()),
821 Some(_) => self.refuse(rel, rel, op, RefusalReason::NotARegularFile),
822 });
823 }
824 };
825 let stat = rustix::fs::fstat(&fd).map_err(|errno| self.io(rel, errno.into()))?;
826 if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
827 return Err(self.refuse(rel, rel, op, RefusalReason::NotARegularFile));
828 }
829 if op != WorkdirOperation::Read && stat.st_nlink > 1 {
830 return Err(self.refuse(rel, rel, op, RefusalReason::HardLink));
831 }
832 Ok(File::from(fd))
833 }
834
835 fn segments<'r>(
838 &self,
839 rel: &'r str,
840 op: WorkdirOperation,
841 ) -> Result<Vec<&'r OsStr>, WorkdirError> {
842 let segments: Vec<&OsStr> = rel.split('/').map(OsStr::new).collect();
843 if segments
844 .iter()
845 .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
846 {
847 return Err(self.refuse(rel, rel, op, RefusalReason::InvalidComponent));
848 }
849 Ok(segments)
850 }
851
852 fn parent_of<'r>(
855 &self,
856 rel: &'r str,
857 op: WorkdirOperation,
858 create: bool,
859 ) -> Result<(Option<Held>, &'r OsStr), WorkdirError> {
860 let segments = self.segments(rel, op)?;
861 let name = *segments.last().expect("split yields at least one segment");
862 let held = self.walk(
863 rel,
864 &segments,
865 segments.len() - 1,
866 op,
867 create.then_some(DEFAULT_DIR_MODE),
868 )?;
869 Ok((held, name))
870 }
871
872 fn walk(
875 &self,
876 rel: &str,
877 segments: &[&OsStr],
878 depth: usize,
879 op: WorkdirOperation,
880 create: Option<u32>,
881 ) -> Result<Option<Held>, WorkdirError> {
882 let mut current = Held::Anchor(self.anchor_fd(create.is_some())?);
883 for index in 0..depth {
884 let name = segments[index];
885 let so_far = segments[..=index]
886 .iter()
887 .map(|segment| segment.to_string_lossy())
888 .collect::<Vec<_>>()
889 .join("/");
890 let next = match rustix::fs::openat(¤t, name, DIR_FLAGS, Mode::empty()) {
891 Ok(fd) => fd,
892 Err(Errno::NOENT) => {
893 let Some(mode) = create else {
894 return Ok(None);
895 };
896 match rustix::fs::mkdirat(¤t, name, mode_of(mode)) {
897 Ok(()) | Err(Errno::EXIST) => {}
898 Err(errno) => return Err(self.io(&so_far, errno.into())),
899 }
900 self.open_dir(¤t, name, rel, &so_far, op)?
901 }
902 Err(errno) => return Err(self.classify(¤t, name, rel, &so_far, op, errno)),
903 };
904 current = Held::Opened(next);
905 }
906 Ok(Some(current))
907 }
908
909 fn open_dir(
910 &self,
911 parent: &impl AsFd,
912 name: &OsStr,
913 rel: &str,
914 so_far: &str,
915 op: WorkdirOperation,
916 ) -> Result<OwnedFd, WorkdirError> {
917 rustix::fs::openat(parent, name, DIR_FLAGS, Mode::empty())
918 .map_err(|errno| self.classify(parent, name, rel, so_far, op, errno))
919 }
920
921 fn classify(
924 &self,
925 parent: &impl AsFd,
926 name: &OsStr,
927 rel: &str,
928 so_far: &str,
929 op: WorkdirOperation,
930 errno: Errno,
931 ) -> WorkdirError {
932 match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
933 Ok(stat) if FileType::from_raw_mode(stat.st_mode) == FileType::Symlink => {
934 self.refuse(rel, so_far, op, RefusalReason::Symlink)
935 }
936 _ => self.io(so_far, errno.into()),
937 }
938 }
939
940 fn leaf_type(
942 &self,
943 parent: &impl AsFd,
944 name: &OsStr,
945 rel: &str,
946 ) -> Result<Option<FileType>, WorkdirError> {
947 match rustix::fs::statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
948 Ok(stat) => Ok(Some(FileType::from_raw_mode(stat.st_mode))),
949 Err(Errno::NOENT) => Ok(None),
950 Err(errno) => Err(self.io(rel, errno.into())),
951 }
952 }
953
954 fn anchor_fd(&self, creating: bool) -> Result<Arc<OwnedFd>, WorkdirError> {
958 let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
959 let removed = creating
960 && !nlink_unreliable()
961 && rustix::fs::fstat(&**held).is_ok_and(|stat| stat.st_nlink == 0);
962 if removed {
963 *held = Arc::new(self.reach_again()?);
964 }
965 Ok(Arc::clone(&held))
966 }
967
968 fn retry_if_removed<T>(
976 &self,
977 call: impl Fn() -> Result<T, WorkdirError>,
978 ) -> Result<T, WorkdirError> {
979 match call() {
980 Err(error) if error.is_not_found() && self.reach_again_if_removed()? => call(),
981 result => result,
982 }
983 }
984
985 fn reach_again_if_removed(&self) -> Result<bool, WorkdirError> {
988 let mut held = self.anchor.fd.lock().unwrap_or_else(|e| e.into_inner());
989 let probe = format!(".{}.probe", uuid::Uuid::now_v7().simple());
990 match rustix::fs::mkdirat(&**held, probe.as_str(), Mode::RWXU) {
991 Ok(()) => {
992 let _ = rustix::fs::unlinkat(&**held, probe.as_str(), AtFlags::REMOVEDIR);
993 Ok(false)
994 }
995 Err(Errno::NOENT) => {
996 *held = Arc::new(self.reach_again()?);
997 Ok(true)
998 }
999 Err(_) => Ok(false),
1000 }
1001 }
1002
1003 fn reach_again(&self) -> Result<OwnedFd, WorkdirError> {
1006 match &self.anchor.origin {
1007 Origin::Path => {
1008 std::fs::create_dir_all(&self.anchor.path).map_err(|source| self.io("", source))?;
1009 open_by_path(&self.anchor.path)
1010 }
1011 Origin::Beneath { from, rel } => {
1012 let op = WorkdirOperation::CreateDir;
1013 let segments = from.segments(rel, op)?;
1014 let walked = from.retry_if_removed(|| {
1015 from.walk(rel, &segments, segments.len(), op, Some(DEFAULT_DIR_MODE))
1016 })?;
1017 let Some(Held::Opened(fd)) = walked else {
1018 unreachable!("a walk that creates always reaches its last segment")
1019 };
1020 Ok(fd)
1021 }
1022 }
1023 }
1024
1025 fn display(&self, rel: &str) -> String {
1026 match (self.anchor.prefix.is_empty(), rel.is_empty()) {
1027 (true, _) => rel.to_string(),
1028 (false, true) => self.anchor.prefix.clone(),
1029 (false, false) => format!("{}/{rel}", self.anchor.prefix),
1030 }
1031 }
1032
1033 fn refuse(
1034 &self,
1035 rel: &str,
1036 refused_at: &str,
1037 operation: WorkdirOperation,
1038 reason: RefusalReason,
1039 ) -> WorkdirError {
1040 let refusal = WorkdirRefusal {
1041 path: self.display(rel),
1042 refused_at: self.display(refused_at),
1043 operation,
1044 reason,
1045 };
1046 self.latch
1047 .lock()
1048 .unwrap_or_else(|e| e.into_inner())
1049 .push(refusal.clone());
1050 WorkdirError::Refused(refusal)
1051 }
1052
1053 fn io(&self, rel: &str, source: std::io::Error) -> WorkdirError {
1054 WorkdirError::Io {
1055 path: self.display(rel),
1056 source,
1057 }
1058 }
1059}
1060
1061pub(crate) fn fd_identity(fd: impl AsFd) -> rustix::io::Result<(u64, u64)> {
1063 let stat = rustix::fs::fstat(fd)?;
1064 Ok((stat.st_dev as u64, stat.st_ino as u64))
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
1071
1072 struct Fixture {
1073 _temp: tempfile::TempDir,
1074 workdir: PathBuf,
1075 outside: PathBuf,
1076 sentinel: PathBuf,
1077 root: WorkdirRoot,
1078 }
1079
1080 const SENTINEL: &[u8] = b"operator data";
1081
1082 fn refusal_of(error: &WorkdirError) -> &WorkdirRefusal {
1083 match error {
1084 WorkdirError::Refused(refusal) => refusal,
1085 other => panic!("expected a refusal, got {other:?}"),
1086 }
1087 }
1088
1089 fn fixture() -> Fixture {
1090 let temp = tempfile::tempdir().unwrap();
1091 let workdir = temp.path().join("workdir");
1092 let outside = temp.path().join("outside");
1093 std::fs::create_dir(&workdir).unwrap();
1094 std::fs::create_dir(&outside).unwrap();
1095 let sentinel = outside.join("sentinel.txt");
1096 std::fs::write(&sentinel, SENTINEL).unwrap();
1097 let root = WorkdirRoot::open(&workdir).unwrap();
1098 Fixture {
1099 _temp: temp,
1100 workdir,
1101 outside,
1102 sentinel,
1103 root,
1104 }
1105 }
1106
1107 impl Fixture {
1108 fn outside_snapshot(&self) -> Vec<(String, Vec<u8>, i64, i64)> {
1109 let mut entries: Vec<_> = std::fs::read_dir(&self.outside)
1110 .unwrap()
1111 .map(|entry| {
1112 let entry = entry.unwrap();
1113 let meta = std::fs::symlink_metadata(entry.path()).unwrap();
1114 let bytes = if meta.is_file() {
1115 std::fs::read(entry.path()).unwrap()
1116 } else {
1117 Vec::new()
1118 };
1119 (
1120 entry.file_name().to_string_lossy().into_owned(),
1121 bytes,
1122 meta.mtime(),
1123 meta.mtime_nsec(),
1124 )
1125 })
1126 .collect();
1127 entries.sort();
1128 entries
1129 }
1130
1131 fn link_at_target(&self, rel: &str) {
1133 let host = self.workdir.join(rel);
1134 std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1135 symlink(&self.sentinel, host).unwrap();
1136 }
1137
1138 fn dangling_at_target(&self, rel: &str) {
1139 let host = self.workdir.join(rel);
1140 std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1141 symlink(self.outside.join("not-yet.txt"), host).unwrap();
1142 }
1143
1144 fn link_at_parent(&self, parent_rel: &str) {
1145 let host = self.workdir.join(parent_rel);
1146 std::fs::create_dir_all(host.parent().unwrap()).unwrap();
1147 symlink(&self.outside, host).unwrap();
1148 }
1149 }
1150
1151 type Operation = fn(&WorkdirRoot, &str) -> Result<(), WorkdirError>;
1152
1153 fn operations() -> Vec<(&'static str, WorkdirOperation, Operation)> {
1154 vec![
1155 ("write_file", WorkdirOperation::Write, |root, rel| {
1156 root.write_file(rel, b"runtime bytes", 0o644)
1157 }),
1158 ("open_append", WorkdirOperation::Append, |root, rel| {
1159 root.open_append(rel, 0o644).and_then(|mut file| {
1160 file.write_all(b"runtime bytes")
1161 .map_err(|source| WorkdirError::Io {
1162 path: rel.to_string(),
1163 source,
1164 })
1165 })
1166 }),
1167 ("read_file", WorkdirOperation::Read, |root, rel| {
1168 root.read_file(rel, u64::MAX).map(|_| ())
1169 }),
1170 (
1171 "create_dir_all",
1172 WorkdirOperation::CreateDir,
1173 |root, rel| root.create_dir_all(rel, 0o755),
1174 ),
1175 ("remove_file", WorkdirOperation::Remove, |root, rel| {
1176 root.remove_file(rel)
1177 }),
1178 ("remove_dir_all", WorkdirOperation::Remove, |root, rel| {
1179 root.remove_dir_all(rel)
1180 }),
1181 ]
1182 }
1183
1184 fn assert_refused(
1185 fixture: &Fixture,
1186 result: Result<(), WorkdirError>,
1187 before: &[(String, Vec<u8>, i64, i64)],
1188 label: &str,
1189 path: &str,
1190 refused_at: &str,
1191 operation: WorkdirOperation,
1192 ) {
1193 let expected = WorkdirRefusal {
1194 path: path.to_string(),
1195 refused_at: refused_at.to_string(),
1196 operation,
1197 reason: RefusalReason::Symlink,
1198 };
1199 match result {
1200 Err(WorkdirError::Refused(refusal)) => assert_eq!(refusal, expected, "{label}"),
1201 other => panic!("{label}: expected a refusal, got {other:?}"),
1202 }
1203 assert_eq!(
1204 fixture.root.take_refusals(),
1205 vec![expected],
1206 "{label}: latched once"
1207 );
1208 assert_eq!(
1209 fixture.outside_snapshot(),
1210 before,
1211 "{label}: outside changed"
1212 );
1213 assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1214 }
1215
1216 #[test]
1217 fn every_operation_refuses_a_link_at_the_target() {
1218 for (label, operation, call) in operations() {
1219 let fixture = fixture();
1220 fixture.link_at_target("dir/target");
1221 let before = fixture.outside_snapshot();
1222 let result = call(&fixture.root, "dir/target");
1223 assert_refused(
1224 &fixture,
1225 result,
1226 &before,
1227 label,
1228 "dir/target",
1229 "dir/target",
1230 operation,
1231 );
1232 assert!(
1233 std::fs::symlink_metadata(fixture.workdir.join("dir/target"))
1234 .unwrap()
1235 .file_type()
1236 .is_symlink()
1237 );
1238 }
1239 }
1240
1241 #[test]
1242 fn every_operation_refuses_a_link_at_a_parent_directory() {
1243 for (label, operation, call) in operations() {
1244 let fixture = fixture();
1245 std::fs::create_dir(fixture.outside.join("dir")).unwrap();
1246 std::fs::write(fixture.outside.join("dir/target"), SENTINEL).unwrap();
1247 fixture.link_at_parent("a/b");
1248 let before = fixture.outside_snapshot();
1249 let result = call(&fixture.root, "a/b/dir/target");
1250 assert_refused(
1251 &fixture,
1252 result,
1253 &before,
1254 label,
1255 "a/b/dir/target",
1256 "a/b",
1257 operation,
1258 );
1259 assert_eq!(
1260 std::fs::read(fixture.outside.join("dir/target")).unwrap(),
1261 SENTINEL
1262 );
1263 }
1264 }
1265
1266 #[test]
1267 fn every_operation_refuses_a_dangling_link_at_the_target() {
1268 for (label, operation, call) in operations() {
1269 let fixture = fixture();
1270 fixture.dangling_at_target("target");
1271 let before = fixture.outside_snapshot();
1272 let result = call(&fixture.root, "target");
1273 assert_refused(
1274 &fixture, result, &before, label, "target", "target", operation,
1275 );
1276 assert!(!fixture.outside.join("not-yet.txt").exists(), "{label}");
1277 }
1278 }
1279
1280 #[test]
1281 fn a_non_plain_component_is_refused_before_any_syscall() {
1282 let fixture = fixture();
1283 for rel in [
1284 "",
1285 "/etc/passwd",
1286 "../outside/sentinel.txt",
1287 "a/./b",
1288 "a//b",
1289 "a/",
1290 ".",
1291 ] {
1292 for (label, operation, call) in operations() {
1293 match call(&fixture.root, rel) {
1294 Err(WorkdirError::Refused(refusal)) => {
1295 assert_eq!(
1296 refusal.reason,
1297 RefusalReason::InvalidComponent,
1298 "{label} {rel}"
1299 );
1300 assert_eq!(refusal.operation, operation);
1301 assert_eq!(refusal.path, rel);
1302 }
1303 other => panic!("{label} {rel:?}: expected a refusal, got {other:?}"),
1304 }
1305 assert_eq!(fixture.root.take_refusals().len(), 1);
1306 }
1307 }
1308 assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1309 assert_eq!(std::fs::read_dir(&fixture.workdir).unwrap().count(), 0);
1310 }
1311
1312 #[test]
1313 fn a_fifo_is_refused_for_append_and_read_without_blocking() {
1314 let fixture = fixture();
1315 let fifo = fixture.workdir.join("pipe");
1316 assert!(std::process::Command::new("mkfifo")
1317 .arg(&fifo)
1318 .status()
1319 .unwrap()
1320 .success());
1321 let append = fixture.root.open_append("pipe", 0o644).map(|_| ());
1322 let read = fixture.root.read_file("pipe", u64::MAX).map(|_| ());
1323 for (result, operation) in [
1324 (append, WorkdirOperation::Append),
1325 (read, WorkdirOperation::Read),
1326 ] {
1327 match result {
1328 Err(WorkdirError::Refused(refusal)) => {
1329 assert_eq!(refusal.reason, RefusalReason::NotARegularFile);
1330 assert_eq!(refusal.operation, operation);
1331 assert_eq!(refusal.refused_at, "pipe");
1332 }
1333 other => panic!("expected a refusal, got {other:?}"),
1334 }
1335 }
1336 assert_eq!(fixture.root.take_refusals().len(), 2);
1337 }
1338
1339 #[test]
1340 fn an_append_to_a_hard_link_into_outside_is_refused() {
1341 let fixture = fixture();
1342 std::fs::hard_link(&fixture.sentinel, fixture.workdir.join("log")).unwrap();
1343 let before = fixture.outside_snapshot();
1344 match fixture.root.open_append("log", 0o644) {
1345 Err(WorkdirError::Refused(refusal)) => {
1346 assert_eq!(refusal.reason, RefusalReason::HardLink);
1347 assert_eq!(refusal.operation, WorkdirOperation::Append);
1348 assert_eq!(refusal.path, "log");
1349 }
1350 other => panic!("expected a refusal, got {other:?}"),
1351 }
1352 assert_eq!(fixture.outside_snapshot(), before);
1353 assert_eq!(fixture.root.take_refusals().len(), 1);
1354 }
1355
1356 #[test]
1357 fn a_write_replaces_an_existing_file_and_leaves_no_temporary_sibling() {
1358 let fixture = fixture();
1359 std::fs::create_dir(fixture.workdir.join("out")).unwrap();
1360 std::fs::write(fixture.workdir.join("out/result.txt"), b"old").unwrap();
1361 fixture
1362 .root
1363 .write_file("out/result.txt", b"new", 0o600)
1364 .unwrap();
1365 assert_eq!(
1366 std::fs::read(fixture.workdir.join("out/result.txt")).unwrap(),
1367 b"new"
1368 );
1369 let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("out"))
1370 .unwrap()
1371 .map(|entry| entry.unwrap().file_name())
1372 .collect();
1373 assert_eq!(names, vec![std::ffi::OsString::from("result.txt")]);
1374 let mode = std::fs::metadata(fixture.workdir.join("out/result.txt"))
1375 .unwrap()
1376 .permissions()
1377 .mode();
1378 assert_eq!(mode & 0o777, 0o600);
1379 assert!(fixture.root.take_refusals().is_empty());
1380 }
1381
1382 #[test]
1383 fn a_link_planted_between_the_check_and_the_rename_is_replaced_not_followed() {
1384 let fixture = fixture();
1385 let target = fixture.workdir.join("task.md");
1386 let sentinel = fixture.sentinel.clone();
1387 let planted = target.clone();
1388 before_next_rename(move || symlink(&sentinel, &planted).unwrap());
1389 let before = fixture.outside_snapshot();
1390 fixture
1391 .root
1392 .write_file("task.md", b"the task", 0o644)
1393 .unwrap();
1394 assert_eq!(fixture.outside_snapshot(), before);
1395 let meta = std::fs::symlink_metadata(&target).unwrap();
1396 assert!(meta.is_file(), "the rename replaced the planted link");
1397 assert_eq!(std::fs::read(&target).unwrap(), b"the task");
1398 }
1399
1400 #[test]
1401 fn a_murmur_link_planted_after_the_session_root_is_held_redirects_nothing() {
1402 let fixture = fixture();
1403 let session = fixture.root.session_beneath("ses_test").unwrap();
1404 let murmur = fixture.workdir.join(".murmur");
1405 std::fs::rename(&murmur, fixture.workdir.join("moved")).unwrap();
1406 symlink(&fixture.outside, &murmur).unwrap();
1407 let before = fixture.outside_snapshot();
1408 session
1409 .write_file("plans/plan-1.json", b"{}", 0o644)
1410 .unwrap();
1411 assert_eq!(fixture.outside_snapshot(), before);
1412 assert_eq!(
1413 std::fs::read(fixture.workdir.join("moved/ses_test/plans/plan-1.json")).unwrap(),
1414 b"{}"
1415 );
1416 }
1417
1418 #[test]
1419 fn a_session_root_refusal_names_the_path_from_the_accessible_workdir_and_shares_the_latch() {
1420 let fixture = fixture();
1421 let session = fixture.root.session_beneath("ses_test").unwrap();
1422 symlink(
1423 &fixture.outside,
1424 fixture.workdir.join(".murmur/ses_test/plans"),
1425 )
1426 .unwrap();
1427 let error = session
1428 .write_file("plans/plan-1.json", b"{}", 0o644)
1429 .unwrap_err();
1430 let refusal = refusal_of(&error);
1431 assert_eq!(refusal.path, ".murmur/ses_test/plans/plan-1.json");
1432 assert_eq!(refusal.refused_at, ".murmur/ses_test/plans");
1433 assert_eq!(
1434 error.to_string(),
1435 "refused to write .murmur/ses_test/plans/plan-1.json: .murmur/ses_test/plans is a \
1436 symbolic link inside the workdir"
1437 );
1438 assert_eq!(fixture.root.take_refusals(), vec![refusal.clone()]);
1439 assert!(session.take_refusals().is_empty());
1440 }
1441
1442 #[test]
1443 fn a_murmur_link_at_session_derivation_is_refused() {
1444 let fixture = fixture();
1445 symlink(&fixture.outside, fixture.workdir.join(".murmur")).unwrap();
1446 let error = fixture.root.session_beneath("ses_test").unwrap_err();
1447 let refusal = refusal_of(&error);
1448 assert_eq!(refusal.refused_at, ".murmur");
1449 assert_eq!(refusal.operation, WorkdirOperation::CreateDir);
1450 assert_eq!(std::fs::read_dir(&fixture.outside).unwrap().count(), 1);
1451 }
1452
1453 #[test]
1454 fn a_removed_session_directory_is_walked_to_again_before_a_write() {
1455 for nlink_unreliable in [false, true] {
1456 NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1457 let fixture = fixture();
1458 let session = fixture.root.session_beneath("ses_test").unwrap();
1459 std::fs::remove_dir_all(fixture.workdir.join(".murmur")).unwrap();
1460
1461 session
1462 .write_file("out/result.txt", b"answer", 0o644)
1463 .unwrap();
1464 session
1465 .open_append("logs/bootstrap.log", 0o644)
1466 .unwrap()
1467 .write_all(b"line\n")
1468 .unwrap();
1469
1470 assert_eq!(
1471 std::fs::read(fixture.workdir.join(".murmur/ses_test/out/result.txt")).unwrap(),
1472 b"answer"
1473 );
1474 assert!(fixture
1475 .workdir
1476 .join(".murmur/ses_test/logs/bootstrap.log")
1477 .is_file());
1478 assert!(fixture.root.take_refusals().is_empty());
1479 }
1480 NLINK_UNRELIABLE.with(|cell| cell.set(false));
1481 }
1482
1483 #[test]
1486 fn a_moved_session_directory_is_still_the_one_written_to() {
1487 NLINK_UNRELIABLE.with(|cell| cell.set(true));
1488 let fixture = fixture();
1489 let session = fixture.root.session_beneath("ses_test").unwrap();
1490 std::fs::rename(
1491 fixture.workdir.join(".murmur"),
1492 fixture.workdir.join("moved"),
1493 )
1494 .unwrap();
1495
1496 session
1497 .write_file("out/result.txt", b"answer", 0o644)
1498 .unwrap();
1499
1500 NLINK_UNRELIABLE.with(|cell| cell.set(false));
1501 assert_eq!(
1502 std::fs::read(fixture.workdir.join("moved/ses_test/out/result.txt")).unwrap(),
1503 b"answer"
1504 );
1505 assert!(!fixture.workdir.join(".murmur").exists());
1506 let names: Vec<_> = std::fs::read_dir(fixture.workdir.join("moved/ses_test"))
1507 .unwrap()
1508 .map(|entry| entry.unwrap().file_name())
1509 .collect();
1510 assert_eq!(names, ["out"]);
1511 }
1512
1513 #[test]
1514 fn a_removed_session_directory_replaced_by_a_link_is_refused() {
1515 for nlink_unreliable in [false, true] {
1516 NLINK_UNRELIABLE.with(|cell| cell.set(nlink_unreliable));
1517 let fixture = fixture();
1518 let session = fixture.root.session_beneath("ses_test").unwrap();
1519 std::fs::remove_dir_all(fixture.workdir.join(".murmur")).unwrap();
1520 std::os::unix::fs::symlink(&fixture.outside, fixture.workdir.join(".murmur")).unwrap();
1521 let before = fixture.outside_snapshot();
1522
1523 let error = session
1524 .write_file("out/result.txt", b"answer", 0o644)
1525 .unwrap_err();
1526
1527 assert_eq!(refusal_of(&error).refused_at, ".murmur");
1528 assert_eq!(fixture.outside_snapshot(), before);
1529 }
1530 NLINK_UNRELIABLE.with(|cell| cell.set(false));
1531 }
1532
1533 #[test]
1534 fn absent_paths_read_as_none_with_no_refusal() {
1535 let fixture = fixture();
1536 assert_eq!(fixture.root.read_file("task.md", 10).unwrap(), None);
1537 assert_eq!(fixture.root.read_to_string("a/b/c").unwrap(), None);
1538 assert_eq!(fixture.root.entry_kind("a/b").unwrap(), None);
1539 assert_eq!(fixture.root.list_dir("tools").unwrap(), None);
1540 assert_eq!(fixture.root.file_len("logs/x.log").unwrap(), None);
1541 assert!(fixture.root.take_refusals().is_empty());
1542 }
1543
1544 #[test]
1545 fn create_dir_all_applies_the_mode_to_what_it_creates() {
1546 let fixture = fixture();
1547 fixture
1548 .root
1549 .create_dir_all(".process-driver/run", 0o700)
1550 .unwrap();
1551 let mode = std::fs::metadata(fixture.workdir.join(".process-driver/run"))
1552 .unwrap()
1553 .permissions()
1554 .mode();
1555 assert_eq!(mode & 0o777, 0o700);
1556 assert_eq!(
1557 fixture.root.entry_kind(".process-driver").unwrap(),
1558 Some(EntryKind::Directory)
1559 );
1560 }
1561
1562 #[test]
1563 fn remove_dir_all_unlinks_a_link_inside_without_following_it() {
1564 let fixture = fixture();
1565 std::fs::create_dir_all(fixture.workdir.join("tools/x/sub")).unwrap();
1566 std::fs::write(fixture.workdir.join("tools/x/sub/file"), b"x").unwrap();
1567 symlink(&fixture.outside, fixture.workdir.join("tools/x/escape")).unwrap();
1568 let before = fixture.outside_snapshot();
1569 fixture.root.remove_dir_all("tools/x").unwrap();
1570 assert!(!fixture.workdir.join("tools/x").exists());
1571 assert_eq!(fixture.outside_snapshot(), before);
1572 }
1573
1574 #[test]
1575 fn overwrite_in_place_keeps_the_inode_and_refuses_a_link() {
1576 let fixture = fixture();
1577 fixture
1578 .root
1579 .overwrite_in_place(".mur-etc/passwd", b"one\n", 0o644)
1580 .unwrap();
1581 let inode = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd"))
1582 .unwrap()
1583 .ino();
1584 fixture
1585 .root
1586 .overwrite_in_place(".mur-etc/passwd", b"two\n", 0o644)
1587 .unwrap();
1588 let meta = std::fs::metadata(fixture.workdir.join(".mur-etc/passwd")).unwrap();
1589 assert_eq!(meta.ino(), inode);
1590 assert_eq!(
1591 std::fs::read(fixture.workdir.join(".mur-etc/passwd")).unwrap(),
1592 b"two\n"
1593 );
1594
1595 fixture.link_at_target(".mur-etc/group");
1596 let error = fixture
1597 .root
1598 .overwrite_in_place(".mur-etc/group", b"x", 0o644)
1599 .unwrap_err();
1600 assert_eq!(refusal_of(&error).reason, RefusalReason::Symlink);
1601 assert_eq!(std::fs::read(&fixture.sentinel).unwrap(), SENTINEL);
1602 }
1603
1604 #[test]
1605 fn list_dir_reports_a_link_inside_as_a_symlink() {
1606 let fixture = fixture();
1607 std::fs::create_dir_all(fixture.workdir.join("tools/a")).unwrap();
1608 symlink(&fixture.outside, fixture.workdir.join("tools/b")).unwrap();
1609 assert_eq!(
1610 fixture.root.list_dir("tools").unwrap().unwrap(),
1611 vec![
1612 ("a".to_string(), EntryKind::Directory),
1613 ("b".to_string(), EntryKind::Symlink)
1614 ]
1615 );
1616 }
1617
1618 #[test]
1619 fn a_refusal_serializes_with_the_wire_names() {
1620 let refusal = WorkdirRefusal {
1621 path: "task.md".into(),
1622 refused_at: "task.md".into(),
1623 operation: WorkdirOperation::CreateDir,
1624 reason: RefusalReason::NotARegularFile,
1625 };
1626 assert_eq!(
1627 serde_json::to_string(&refusal).unwrap(),
1628 r#"{"path":"task.md","refused_at":"task.md","operation":"create_dir","reason":"not_a_regular_file"}"#
1629 );
1630 for (op, wire) in [
1631 (WorkdirOperation::Write, "write"),
1632 (WorkdirOperation::Append, "append"),
1633 (WorkdirOperation::Read, "read"),
1634 (WorkdirOperation::CreateDir, "create_dir"),
1635 (WorkdirOperation::Remove, "remove"),
1636 ] {
1637 assert_eq!(serde_json::to_value(op).unwrap(), serde_json::json!(wire));
1638 }
1639 for (reason, wire) in [
1640 (RefusalReason::Symlink, "symlink"),
1641 (RefusalReason::HardLink, "hard_link"),
1642 (RefusalReason::NotARegularFile, "not_a_regular_file"),
1643 (RefusalReason::InvalidComponent, "invalid_component"),
1644 ] {
1645 assert_eq!(
1646 serde_json::to_value(reason).unwrap(),
1647 serde_json::json!(wire)
1648 );
1649 }
1650 }
1651}