1use std::ffi::{OsStr, OsString};
39use std::fs::{File, FileTimes, Permissions};
40use std::io::Read;
41use std::os::fd::{AsFd, OwnedFd};
42use std::os::unix::ffi::OsStrExt;
43use std::os::unix::fs::PermissionsExt;
44use std::path::{Path, PathBuf};
45
46use rustix::fs::{AtFlags, FileType, Mode, OFlags};
47use rustix::io::Errno;
48use serde::{Deserialize, Serialize};
49
50use crate::containment::FilesystemRestriction;
51use crate::workdir_fs::{fd_identity, WorkdirRoot};
52
53pub const SKIPPED_SAMPLE_LIMIT: usize = 20;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum SkipReason {
61 Fifo,
62 Socket,
63 Device,
65 Unreadable,
67 RuntimeOwned,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SkippedEntry {
74 pub path: String,
76 pub reason: SkipReason,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct WorkdirSnapshot {
87 pub source: String,
89 pub files: u64,
91 pub dirs: u64,
93 pub symlinks: u64,
95 pub repointed_symlinks: u64,
97 pub bytes: u64,
99 pub skipped_count: u64,
101 pub skipped: Vec<SkippedEntry>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct ParentWorkdirReport {
109 #[serde(flatten)]
110 pub snapshot: WorkdirSnapshot,
111 pub restriction: FilesystemRestriction,
115}
116
117impl ParentWorkdirReport {
118 #[must_use]
120 pub fn restriction_sentence(&self) -> &'static str {
121 match self.restriction {
122 FilesystemRestriction::Absent => {
123 "absent: the parent's work folder is not in this session's composed root"
124 }
125 FilesystemRestriction::Enforced => {
126 "enforced: Landlock refuses every open of the parent's work folder"
127 }
128 FilesystemRestriction::Advisory => {
129 "advisory: nothing in the kernel stops a command that names the parent's work \
130 folder by its absolute path; the runtime grants it nowhere"
131 }
132 FilesystemRestriction::None => {
133 "none: this session runs unconfined, so a command can reach the parent's work \
134 folder as the operator can"
135 }
136 }
137 }
138}
139
140#[derive(Debug, thiserror::Error)]
142pub enum SnapshotError {
143 #[error(
145 "the copy of the parent's work folder passes the child's \
146 capabilities.resources.workdir_max_bytes ceiling of {max_bytes} bytes at {path}"
147 )]
148 Ceiling { max_bytes: u64, path: String },
149 #[error("copying the parent's work folder failed at {path}: {source}")]
151 Io {
152 path: String,
153 #[source]
154 source: std::io::Error,
155 },
156}
157
158#[derive(Debug, Clone, Default)]
160pub struct SnapshotLimits {
161 pub max_bytes: u64,
163 pub excluded: Vec<(u64, u64)>,
166}
167
168pub fn copy_workdir(
174 source: &WorkdirRoot,
175 destination: &WorkdirRoot,
176 limits: &SnapshotLimits,
177) -> Result<WorkdirSnapshot, SnapshotError> {
178 let root_error = |path: &str, error: crate::workdir_fs::WorkdirError| SnapshotError::Io {
179 path: path.to_string(),
180 source: error.into(),
181 };
182 let source_fd = source
183 .dir_fd()
184 .map_err(|error| root_error(&source.path().display().to_string(), error))?;
185 let destination_fd = destination
186 .dir_fd()
187 .map_err(|error| root_error(&destination.path().display().to_string(), error))?;
188 let mut excluded = limits.excluded.clone();
189 excluded.push(
190 destination
191 .identity()
192 .map_err(|error| root_error(&destination.path().display().to_string(), error))?,
193 );
194 let runtime_owned: Vec<String> = crate::workdir_writes::runtime_writes()
195 .into_iter()
196 .map(|write| write.path)
197 .collect();
198
199 let mut walk = Walk {
200 max_bytes: limits.max_bytes,
201 excluded,
202 runtime_owned,
203 source_prefixes: link_prefixes(source.path()),
204 destination: destination.path().to_path_buf(),
205 snapshot: WorkdirSnapshot {
206 source: source.path().display().to_string(),
207 files: 0,
208 dirs: 0,
209 symlinks: 0,
210 repointed_symlinks: 0,
211 bytes: 0,
212 skipped_count: 0,
213 skipped: Vec::new(),
214 },
215 };
216 walk.copy_dir(&source_fd, &destination_fd, "")?;
217 Ok(walk.snapshot)
218}
219
220fn link_prefixes(source: &Path) -> Vec<PathBuf> {
223 let mut prefixes = vec![source.to_path_buf()];
224 if let Ok(resolved) = std::fs::canonicalize(source) {
225 if resolved != source {
226 prefixes.push(resolved);
227 }
228 }
229 prefixes
230}
231
232struct Walk {
233 max_bytes: u64,
234 excluded: Vec<(u64, u64)>,
235 runtime_owned: Vec<String>,
236 source_prefixes: Vec<PathBuf>,
237 destination: PathBuf,
238 snapshot: WorkdirSnapshot,
239}
240
241const READ_DIR: OFlags = OFlags::RDONLY
242 .union(OFlags::DIRECTORY)
243 .union(OFlags::NOFOLLOW)
244 .union(OFlags::CLOEXEC);
245
246const READ_FILE: OFlags = OFlags::RDONLY
248 .union(OFlags::NOFOLLOW)
249 .union(OFlags::NONBLOCK)
250 .union(OFlags::CLOEXEC);
251
252const CREATE_FILE: OFlags = OFlags::WRONLY
253 .union(OFlags::CREATE)
254 .union(OFlags::EXCL)
255 .union(OFlags::NOFOLLOW)
256 .union(OFlags::CLOEXEC);
257
258const PERMISSION_BITS: u32 = 0o777;
260
261impl Walk {
262 fn skip(&mut self, path: String, reason: SkipReason) {
263 self.snapshot.skipped_count += 1;
264 if self.snapshot.skipped.len() < SKIPPED_SAMPLE_LIMIT {
265 self.snapshot.skipped.push(SkippedEntry { path, reason });
266 }
267 }
268
269 fn io(&self, rel: &str, source: impl Into<std::io::Error>) -> SnapshotError {
270 SnapshotError::Io {
271 path: self.destination.join(rel).display().to_string(),
272 source: source.into(),
273 }
274 }
275
276 fn copy_dir(
279 &mut self,
280 source: &OwnedFd,
281 destination: &OwnedFd,
282 rel: &str,
283 ) -> Result<(), SnapshotError> {
284 let names = match list(source) {
285 Ok(names) => names,
286 Err(_) if !rel.is_empty() => {
287 self.skip(rel.to_string(), SkipReason::Unreadable);
288 return Ok(());
289 }
290 Err(errno) => return Err(self.io(rel, errno)),
291 };
292 for name in names {
293 let child = if rel.is_empty() {
294 name.to_string_lossy().into_owned()
295 } else {
296 format!("{rel}/{}", name.to_string_lossy())
297 };
298 if rel.is_empty()
299 && self
300 .runtime_owned
301 .iter()
302 .any(|owned| name == owned.as_str())
303 {
304 self.skip(child, SkipReason::RuntimeOwned);
305 continue;
306 }
307 let stat = match rustix::fs::statat(source, &name, AtFlags::SYMLINK_NOFOLLOW) {
308 Ok(stat) => stat,
309 Err(Errno::NOENT) => continue,
311 Err(_) => {
312 self.skip(child, SkipReason::Unreadable);
313 continue;
314 }
315 };
316 match FileType::from_raw_mode(stat.st_mode) {
317 FileType::RegularFile => {
318 self.copy_file(source, destination, &name, &child, stat.st_size as u64)?
319 }
320 FileType::Directory => self.copy_subdir(source, destination, &name, &child)?,
321 FileType::Symlink => self.copy_link(source, destination, &name, &child)?,
322 FileType::Fifo => self.skip(child, SkipReason::Fifo),
323 FileType::Socket => self.skip(child, SkipReason::Socket),
324 FileType::CharacterDevice | FileType::BlockDevice => {
325 self.skip(child, SkipReason::Device)
326 }
327 FileType::Unknown => self.skip(child, SkipReason::Unreadable),
328 }
329 }
330 Ok(())
331 }
332
333 fn check_ceiling(&self, rel: &str, more: u64) -> Result<(), SnapshotError> {
334 if self.snapshot.bytes.saturating_add(more) > self.max_bytes {
335 return Err(SnapshotError::Ceiling {
336 max_bytes: self.max_bytes,
337 path: rel.to_string(),
338 });
339 }
340 Ok(())
341 }
342
343 fn copy_file(
344 &mut self,
345 source: &OwnedFd,
346 destination: &OwnedFd,
347 name: &OsStr,
348 rel: &str,
349 size: u64,
350 ) -> Result<(), SnapshotError> {
351 self.check_ceiling(rel, size)?;
352 let opened = match rustix::fs::openat(source, name, READ_FILE, Mode::empty()) {
353 Ok(fd) => fd,
354 Err(Errno::NOENT) => return Ok(()),
355 Err(_) => {
356 self.skip(rel.to_string(), SkipReason::Unreadable);
357 return Ok(());
358 }
359 };
360 let mut input = File::from(opened);
361 let metadata = match input.metadata() {
362 Ok(metadata) if metadata.file_type().is_file() => metadata,
363 _ => {
364 self.skip(rel.to_string(), SkipReason::Unreadable);
365 return Ok(());
366 }
367 };
368 let created = rustix::fs::openat(destination, name, CREATE_FILE, Mode::RUSR | Mode::WUSR)
369 .map_err(|errno| self.io(rel, errno))?;
370 let mut output = File::from(created);
371 let room = self.max_bytes.saturating_sub(self.snapshot.bytes);
374 let copied =
375 match std::io::copy(&mut (&mut input).take(room.saturating_add(1)), &mut output) {
376 Ok(copied) => copied,
377 Err(error) if is_read_side(&error) => {
379 drop(output);
380 let _ = rustix::fs::unlinkat(destination, name, AtFlags::empty());
381 self.skip(rel.to_string(), SkipReason::Unreadable);
382 return Ok(());
383 }
384 Err(error) => return Err(self.io(rel, error)),
385 };
386 self.check_ceiling(rel, copied)?;
387 output
388 .set_permissions(Permissions::from_mode(
389 metadata.permissions().mode() & PERMISSION_BITS,
390 ))
391 .map_err(|error| self.io(rel, error))?;
392 if let Ok(modified) = metadata.modified() {
393 output
394 .set_times(FileTimes::new().set_modified(modified))
395 .map_err(|error| self.io(rel, error))?;
396 }
397 self.snapshot.bytes += copied;
398 self.snapshot.files += 1;
399 Ok(())
400 }
401
402 fn copy_subdir(
403 &mut self,
404 source: &OwnedFd,
405 destination: &OwnedFd,
406 name: &OsStr,
407 rel: &str,
408 ) -> Result<(), SnapshotError> {
409 let opened = match rustix::fs::openat(source, name, READ_DIR, Mode::empty()) {
410 Ok(fd) => fd,
411 Err(Errno::NOENT) => return Ok(()),
412 Err(_) => {
413 self.skip(rel.to_string(), SkipReason::Unreadable);
414 return Ok(());
415 }
416 };
417 match fd_identity(&opened) {
418 Ok(identity) if self.excluded.contains(&identity) => {
419 self.skip(rel.to_string(), SkipReason::RuntimeOwned);
420 return Ok(());
421 }
422 Ok(_) => {}
423 Err(_) => {
424 self.skip(rel.to_string(), SkipReason::Unreadable);
425 return Ok(());
426 }
427 }
428 rustix::fs::mkdirat(destination, name, Mode::RWXU).map_err(|errno| self.io(rel, errno))?;
431 let created = rustix::fs::openat(destination, name, READ_DIR, Mode::empty())
432 .map_err(|errno| self.io(rel, errno))?;
433 self.snapshot.dirs += 1;
434 self.copy_dir(&opened, &created, rel)?;
435
436 let metadata = File::from(opened)
437 .metadata()
438 .map_err(|error| self.io(rel, error))?;
439 let created = File::from(created);
440 created
441 .set_permissions(Permissions::from_mode(
442 metadata.permissions().mode() & PERMISSION_BITS,
443 ))
444 .map_err(|error| self.io(rel, error))?;
445 if let Ok(modified) = metadata.modified() {
446 created
447 .set_times(FileTimes::new().set_modified(modified))
448 .map_err(|error| self.io(rel, error))?;
449 }
450 Ok(())
451 }
452
453 fn copy_link(
454 &mut self,
455 source: &OwnedFd,
456 destination: &OwnedFd,
457 name: &OsStr,
458 rel: &str,
459 ) -> Result<(), SnapshotError> {
460 let target = match rustix::fs::readlinkat(source, name, Vec::new()) {
461 Ok(target) => target,
462 Err(Errno::NOENT) => return Ok(()),
463 Err(_) => {
464 self.skip(rel.to_string(), SkipReason::Unreadable);
465 return Ok(());
466 }
467 };
468 let target = Path::new(OsStr::from_bytes(target.as_bytes()));
469 let repointed = self.repoint(target);
470 let written = repointed.as_deref().unwrap_or(target);
471 rustix::fs::symlinkat(written, destination, name).map_err(|errno| self.io(rel, errno))?;
472 self.snapshot.symlinks += 1;
473 if repointed.is_some() {
474 self.snapshot.repointed_symlinks += 1;
475 }
476 Ok(())
477 }
478
479 fn repoint(&self, target: &Path) -> Option<PathBuf> {
482 if !target.is_absolute() {
483 return None;
484 }
485 self.source_prefixes.iter().find_map(|prefix| {
486 let rest = target.strip_prefix(prefix).ok()?;
487 Some(if rest.as_os_str().is_empty() {
488 self.destination.clone()
489 } else {
490 self.destination.join(rest)
491 })
492 })
493 }
494}
495
496fn list(dir: &impl AsFd) -> rustix::io::Result<Vec<OsString>> {
498 let mut listing = rustix::fs::Dir::read_from(dir)?;
499 let mut names = Vec::new();
500 while let Some(entry) = listing.read() {
501 let name = OsStr::from_bytes(entry?.file_name().to_bytes()).to_os_string();
502 if name != "." && name != ".." {
503 names.push(name);
504 }
505 }
506 names.sort();
507 Ok(names)
508}
509
510fn is_read_side(error: &std::io::Error) -> bool {
514 matches!(
515 error.raw_os_error().map(Errno::from_raw_os_error),
516 Some(Errno::ACCESS | Errno::PERM)
517 )
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use std::os::unix::fs::{symlink, MetadataExt};
524 use std::time::{Duration, SystemTime};
525
526 struct Fixture {
527 temp: tempfile::TempDir,
528 source: PathBuf,
529 destination: PathBuf,
530 }
531
532 impl Fixture {
533 fn new() -> Self {
534 let temp = tempfile::tempdir().unwrap();
535 let source = temp.path().join("parent");
536 let destination = temp.path().join("child");
537 std::fs::create_dir(&source).unwrap();
538 std::fs::create_dir(&destination).unwrap();
539 Self {
540 temp,
541 source,
542 destination,
543 }
544 }
545
546 fn copy(&self, limits: &SnapshotLimits) -> Result<WorkdirSnapshot, SnapshotError> {
547 copy_workdir(
548 &WorkdirRoot::open(&self.source).unwrap(),
549 &WorkdirRoot::open(&self.destination).unwrap(),
550 limits,
551 )
552 }
553 }
554
555 fn unbounded() -> SnapshotLimits {
556 SnapshotLimits {
557 max_bytes: crate::resources::DEFAULT_WORKDIR_MAX_BYTES,
558 excluded: Vec::new(),
559 }
560 }
561
562 fn mkfifo(path: &Path) {
563 let status = std::process::Command::new("mkfifo")
564 .arg(path)
565 .status()
566 .unwrap();
567 assert!(status.success(), "mkfifo {}", path.display());
568 }
569
570 fn write(path: &Path, bytes: &[u8], mode: u32) {
571 std::fs::write(path, bytes).unwrap();
572 std::fs::set_permissions(path, Permissions::from_mode(mode)).unwrap();
573 }
574
575 #[test]
576 fn workdir_snapshot_copies_contents_permission_bits_and_times() {
577 let fixture = Fixture::new();
578 let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000);
579 write(&fixture.source.join("notes.txt"), b"hello", 0o640);
580 write(&fixture.source.join("check.sh"), b"#!/bin/sh\n", 0o755);
581 File::options()
582 .write(true)
583 .open(fixture.source.join("notes.txt"))
584 .unwrap()
585 .set_modified(old)
586 .unwrap();
587 std::fs::create_dir_all(fixture.source.join("src/deep")).unwrap();
588 write(&fixture.source.join("src/deep/lib.rs"), b"fn x() {}", 0o600);
589 std::fs::set_permissions(
590 fixture.source.join("src/deep"),
591 Permissions::from_mode(0o750),
592 )
593 .unwrap();
594 File::open(fixture.source.join("src"))
595 .unwrap()
596 .set_modified(old)
597 .unwrap();
598
599 let snapshot = fixture.copy(&unbounded()).unwrap();
600
601 assert_eq!(snapshot.source, fixture.source.display().to_string());
602 assert_eq!(
603 (
604 snapshot.files,
605 snapshot.dirs,
606 snapshot.symlinks,
607 snapshot.bytes
608 ),
609 (3, 2, 0, 5 + 10 + 9)
610 );
611 assert_eq!(snapshot.skipped_count, 0);
612 let copied = |rel: &str| fixture.destination.join(rel);
613 assert_eq!(std::fs::read(copied("notes.txt")).unwrap(), b"hello");
614 assert_eq!(
615 std::fs::read(copied("src/deep/lib.rs")).unwrap(),
616 b"fn x() {}"
617 );
618 let mode = |rel: &str| std::fs::symlink_metadata(copied(rel)).unwrap().mode() & 0o7777;
619 assert_eq!(mode("notes.txt"), 0o640);
620 assert_eq!(mode("check.sh"), 0o755);
621 assert_eq!(mode("src/deep"), 0o750);
622 let modified = |rel: &str| std::fs::metadata(copied(rel)).unwrap().modified().unwrap();
623 assert_eq!(modified("notes.txt"), old);
624 assert_eq!(
625 modified("src"),
626 old,
627 "a directory's time is set after its contents"
628 );
629 }
630
631 #[test]
632 fn workdir_snapshot_drops_setuid_setgid_and_sticky() {
633 let fixture = Fixture::new();
634 write(&fixture.source.join("tool"), b"x", 0o6755);
635 std::fs::create_dir(fixture.source.join("shared")).unwrap();
636 std::fs::set_permissions(
637 fixture.source.join("shared"),
638 Permissions::from_mode(0o1777),
639 )
640 .unwrap();
641
642 fixture.copy(&unbounded()).unwrap();
643
644 let mode = |rel: &str| {
645 std::fs::symlink_metadata(fixture.destination.join(rel))
646 .unwrap()
647 .mode()
648 & 0o7777
649 };
650 assert_eq!(mode("tool"), 0o755);
651 assert_eq!(mode("shared"), 0o777);
652 }
653
654 #[test]
655 fn workdir_snapshot_recreates_links_without_following_them_and_repoints_into_the_copy() {
656 let fixture = Fixture::new();
657 let outside = fixture.temp.path().join("outside.txt");
658 write(&outside, b"outside", 0o644);
659 write(&fixture.source.join("real.txt"), b"real", 0o644);
660 symlink("real.txt", fixture.source.join("relative")).unwrap();
661 symlink(&outside, fixture.source.join("absolute-outside")).unwrap();
662 symlink(
663 fixture.source.join("real.txt"),
664 fixture.source.join("absolute-inside"),
665 )
666 .unwrap();
667 symlink(&fixture.source, fixture.source.join("root-link")).unwrap();
668 symlink("/nonexistent/dangling", fixture.source.join("dangling")).unwrap();
669
670 let snapshot = fixture.copy(&unbounded()).unwrap();
671
672 assert_eq!(snapshot.files, 1, "no link was followed into a copied file");
673 assert_eq!(snapshot.bytes, 4);
674 assert_eq!(snapshot.symlinks, 5);
675 assert_eq!(snapshot.repointed_symlinks, 2);
676 let target = |rel: &str| std::fs::read_link(fixture.destination.join(rel)).unwrap();
677 assert_eq!(target("relative"), PathBuf::from("real.txt"));
678 assert_eq!(target("absolute-outside"), outside);
679 assert_eq!(target("dangling"), PathBuf::from("/nonexistent/dangling"));
680 assert_eq!(
681 target("absolute-inside"),
682 fixture.destination.join("real.txt")
683 );
684 assert_eq!(target("root-link"), fixture.destination);
685 assert_eq!(
686 std::fs::read(fixture.destination.join("absolute-inside")).unwrap(),
687 b"real"
688 );
689 }
690
691 #[test]
692 fn workdir_snapshot_skips_a_fifo_without_blocking_and_an_unreadable_file() {
693 let fixture = Fixture::new();
694 mkfifo(&fixture.source.join("pipe"));
695 write(&fixture.source.join("secret"), b"nope", 0o000);
696 write(&fixture.source.join("plain"), b"ok", 0o644);
697 let readable_anyway = File::open(fixture.source.join("secret")).is_ok();
698
699 let snapshot = fixture.copy(&unbounded()).unwrap();
700
701 assert!(snapshot.skipped.contains(&SkippedEntry {
702 path: "pipe".to_string(),
703 reason: SkipReason::Fifo,
704 }));
705 assert!(!fixture.destination.join("pipe").exists());
706 if !readable_anyway {
707 assert!(snapshot.skipped.contains(&SkippedEntry {
708 path: "secret".to_string(),
709 reason: SkipReason::Unreadable,
710 }));
711 assert!(!fixture.destination.join("secret").exists());
712 assert_eq!(snapshot.skipped_count, 2);
713 }
714 assert_eq!(
715 std::fs::read(fixture.destination.join("plain")).unwrap(),
716 b"ok"
717 );
718 }
719
720 #[test]
721 fn workdir_snapshot_skips_top_level_runtime_writes_only() {
722 let fixture = Fixture::new();
723 for dir in ["out", "peer-in", "nested/out"] {
724 std::fs::create_dir_all(fixture.source.join(dir)).unwrap();
725 write(&fixture.source.join(dir).join("f"), b"x", 0o644);
726 }
727
728 let snapshot = fixture.copy(&unbounded()).unwrap();
729
730 assert_eq!(
731 snapshot.skipped,
732 vec![
733 SkippedEntry {
734 path: "out".to_string(),
735 reason: SkipReason::RuntimeOwned,
736 },
737 SkippedEntry {
738 path: "peer-in".to_string(),
739 reason: SkipReason::RuntimeOwned,
740 },
741 ]
742 );
743 assert!(!fixture.destination.join("out").exists());
744 assert!(!fixture.destination.join("peer-in").exists());
745 assert!(fixture.destination.join("nested/out/f").is_file());
746 }
747
748 #[test]
749 fn workdir_snapshot_does_not_enter_an_excluded_identity_or_its_own_destination() {
750 let temp = tempfile::tempdir().unwrap();
751 let source = temp.path().join("work");
752 let records = source.join(".murmur/records");
753 let destination = records.join("children/reviewer-0");
754 std::fs::create_dir_all(&destination).unwrap();
755 std::fs::create_dir_all(source.join(".murmur/runtime")).unwrap();
756 write(&records.join("trace.jsonl"), b"{}", 0o644);
757 write(&source.join(".murmur/runtime/MURMUR.md"), b"#", 0o644);
758 write(&source.join("code.rs"), b"fn", 0o644);
759 let runtime = WorkdirRoot::open(&source.join(".murmur/runtime")).unwrap();
760
761 let snapshot = copy_workdir(
762 &WorkdirRoot::open(&source).unwrap(),
763 &WorkdirRoot::open(&destination).unwrap(),
764 &SnapshotLimits {
765 max_bytes: 1 << 20,
766 excluded: vec![runtime.identity().unwrap()],
767 },
768 )
769 .unwrap();
770
771 assert_eq!(snapshot.files, 2, "{snapshot:?}");
774 assert!(destination.join("code.rs").is_file());
775 assert!(!destination.join(".murmur/runtime").exists());
776 assert!(snapshot.skipped.contains(&SkippedEntry {
777 path: ".murmur/runtime".to_string(),
778 reason: SkipReason::RuntimeOwned,
779 }));
780 assert!(snapshot.skipped.contains(&SkippedEntry {
781 path: ".murmur/records/children/reviewer-0".to_string(),
782 reason: SkipReason::RuntimeOwned,
783 }));
784 assert!(!destination
785 .join(".murmur/records/children/reviewer-0")
786 .exists());
787 }
788
789 #[test]
790 fn workdir_snapshot_stops_at_the_ceiling() {
791 let fixture = Fixture::new();
792 write(&fixture.source.join("big.bin"), &[7u8; 4096], 0o644);
793
794 let error = fixture
795 .copy(&SnapshotLimits {
796 max_bytes: 1024,
797 excluded: Vec::new(),
798 })
799 .unwrap_err();
800
801 assert!(
802 matches!(&error, SnapshotError::Ceiling { max_bytes: 1024, path } if path == "big.bin"),
803 "{error:?}"
804 );
805 let message = error.to_string();
806 assert!(
807 message.contains("capabilities.resources.workdir_max_bytes")
808 && message.contains("1024"),
809 "{message}"
810 );
811
812 let fresh = Fixture::new();
813 write(&fresh.source.join("exact.bin"), &[1u8; 1024], 0o644);
814 let snapshot = fresh
815 .copy(&SnapshotLimits {
816 max_bytes: 1024,
817 excluded: Vec::new(),
818 })
819 .unwrap();
820 assert_eq!(snapshot.bytes, 1024, "exactly the ceiling fits");
821 }
822
823 #[test]
824 fn workdir_snapshot_keeps_at_most_twenty_samples_and_counts_every_skip() {
825 let fixture = Fixture::new();
826 for index in 0..25 {
827 mkfifo(&fixture.source.join(format!("pipe-{index:02}")));
828 }
829
830 let snapshot = fixture.copy(&unbounded()).unwrap();
831
832 assert_eq!(snapshot.skipped_count, 25);
833 assert_eq!(snapshot.skipped.len(), SKIPPED_SAMPLE_LIMIT);
834 assert_eq!(snapshot.skipped[0].path, "pipe-00");
835 }
836
837 #[test]
838 fn workdir_snapshot_serializes_the_documented_keys() {
839 let snapshot = WorkdirSnapshot {
840 source: "/home/you/project".to_string(),
841 files: 12,
842 dirs: 3,
843 symlinks: 1,
844 repointed_symlinks: 0,
845 bytes: 40960,
846 skipped_count: 1,
847 skipped: vec![SkippedEntry {
848 path: "run/pipe".to_string(),
849 reason: SkipReason::Fifo,
850 }],
851 };
852 let json = serde_json::to_string(&snapshot).unwrap();
853 assert_eq!(
854 json,
855 r#"{"source":"/home/you/project","files":12,"dirs":3,"symlinks":1,"repointed_symlinks":0,"bytes":40960,"skipped_count":1,"skipped":[{"path":"run/pipe","reason":"fifo"}]}"#
856 );
857 assert_eq!(
858 serde_json::from_str::<WorkdirSnapshot>(&json).unwrap(),
859 snapshot
860 );
861 let report = serde_json::to_value(ParentWorkdirReport {
862 snapshot,
863 restriction: FilesystemRestriction::Enforced,
864 })
865 .unwrap();
866 assert_eq!(report["restriction"], "enforced");
867 assert_eq!(report["files"], 12);
868 for reason in [
869 (SkipReason::Socket, "socket"),
870 (SkipReason::Device, "device"),
871 (SkipReason::Unreadable, "unreadable"),
872 (SkipReason::RuntimeOwned, "runtime-owned"),
873 ] {
874 assert_eq!(serde_json::to_value(reason.0).unwrap(), reason.1);
875 }
876 }
877
878 #[test]
879 fn workdir_snapshot_leaves_the_source_unchanged() {
880 let fixture = Fixture::new();
881 write(&fixture.source.join("a"), b"a", 0o444);
882 std::fs::create_dir(fixture.source.join("ro")).unwrap();
883 write(&fixture.source.join("ro/b"), b"b", 0o644);
884 std::fs::set_permissions(fixture.source.join("ro"), Permissions::from_mode(0o555)).unwrap();
885 let before = digest(&fixture.source);
886
887 fixture.copy(&unbounded()).unwrap();
888 WorkdirRoot::open(fixture.destination.parent().unwrap())
890 .unwrap()
891 .remove_dir_all("child")
892 .unwrap();
893
894 assert_eq!(digest(&fixture.source), before);
895 std::fs::set_permissions(fixture.source.join("ro"), Permissions::from_mode(0o755)).unwrap();
896 }
897
898 fn digest(root: &Path) -> Vec<(String, u32, Vec<u8>)> {
899 let mut entries = Vec::new();
900 let mut stack = vec![root.to_path_buf()];
901 while let Some(dir) = stack.pop() {
902 for entry in std::fs::read_dir(&dir).unwrap() {
903 let path = entry.unwrap().path();
904 let metadata = std::fs::symlink_metadata(&path).unwrap();
905 let contents = if metadata.is_file() {
906 std::fs::read(&path).unwrap()
907 } else {
908 Vec::new()
909 };
910 if metadata.is_dir() {
911 stack.push(path.clone());
912 }
913 entries.push((
914 path.strip_prefix(root).unwrap().display().to_string(),
915 metadata.mode(),
916 contents,
917 ));
918 }
919 }
920 entries.sort();
921 entries
922 }
923}