Skip to main content

capsule_runtime/
workdir_snapshot.rs

1//! Copying a delegating capsule's work folder into a delegated child's own, before the child
2//! process starts.
3//!
4//! A parent lists a sub-capsule under `capabilities.spawn.workdir_snapshot`, and every delegation
5//! to it starts the child in a work folder that already holds a copy of the parent's as it stood
6//! at that moment. The parent's folder is the read-only side — the child is never handed any path
7//! to it — and the child's own folder is the writable layer. A copy rather than a mount, because a
8//! containment class is installed on the shell-subprocess tree only while WASM tools and the
9//! runtime read the work folder from the host mount namespace: one copied folder is the same
10//! folder to every reader, on every tier and platform.
11//!
12//! # The walk
13//!
14//! Every read of the source and every write of the destination is relative to a held directory
15//! descriptor, and no symbolic link is followed on either side:
16//!
17//! | Entry | Copied as |
18//! |---|---|
19//! | Regular file | Its content, its `rwx` bits for user, group and other, and its modification time. Setuid, setgid and sticky are dropped. |
20//! | Directory | Recreated with its `rwx` bits; its modification time is set after its contents. |
21//! | Symbolic link | Recreated, never followed. An absolute target inside the source is re-pointed at the same place in the destination; every other target is copied verbatim. |
22//! | FIFO, socket, device node | Not copied: [`SkipReason::Fifo`], [`SkipReason::Socket`], [`SkipReason::Device`]. |
23//! | An entry the runtime cannot open | Not copied: [`SkipReason::Unreadable`]. |
24//! | A top-level runtime write ([`crate::workdir_writes::runtime_writes`]) | Not copied: [`SkipReason::RuntimeOwned`]. |
25//! | A directory whose `(st_dev, st_ino)` is excluded, or is the destination itself | Not entered: [`SkipReason::RuntimeOwned`]. |
26//!
27//! The exclusion by identity is what stops the copy recursing into itself when the parent's
28//! runtime or records directory, and with it the child's new directory, lies inside the work
29//! folder — an `advisory`-capped launch.
30//!
31//! # The ceiling
32//!
33//! The bytes of every copied regular file count against the child's own
34//! `capabilities.resources.workdir_max_bytes`. Crossing it stops the copy with
35//! [`SnapshotError::Ceiling`]; so does any write that fails. The caller removes the partial
36//! destination and starts no process.
37
38use 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
53/// How many skipped entries a [`WorkdirSnapshot`] names; [`WorkdirSnapshot::skipped_count`] is
54/// the total.
55pub const SKIPPED_SAMPLE_LIMIT: usize = 20;
56
57/// Why an entry of the source was not copied.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum SkipReason {
61    Fifo,
62    Socket,
63    /// A character or block device node.
64    Device,
65    /// The runtime could not open or read it.
66    Unreadable,
67    /// A path the runtime writes into a work folder, or one of its own directories.
68    RuntimeOwned,
69}
70
71/// One entry of the source the copy left out.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SkippedEntry {
74    /// Relative to the source work folder.
75    pub path: String,
76    pub reason: SkipReason,
77}
78
79/// What one copy of a parent's work folder put into a child's: the parent's
80/// `delegation_start.workdir_snapshot`, and what the child is told through
81/// `mur run --parent-workdir-snapshot`.
82///
83/// A report of what the parent did, never a grant: it names the parent's folder and carries no
84/// token, credential or approval.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct WorkdirSnapshot {
87    /// The parent's work folder, as a host path.
88    pub source: String,
89    /// Regular files copied.
90    pub files: u64,
91    /// Directories recreated beneath the destination, not counting the destination itself.
92    pub dirs: u64,
93    /// Symbolic links recreated, [`Self::repointed_symlinks`] among them.
94    pub symlinks: u64,
95    /// Links whose absolute target lay inside the source and now points into the destination.
96    pub repointed_symlinks: u64,
97    /// Bytes of regular-file content copied: what counted against the ceiling.
98    pub bytes: u64,
99    /// Every entry left out.
100    pub skipped_count: u64,
101    /// The first [`SKIPPED_SAMPLE_LIMIT`] entries left out, in walk order.
102    pub skipped: Vec<SkippedEntry>,
103}
104
105/// The child's `session_start.effective_grants.parent_workdir`: the snapshot it started with, and
106/// what keeps its commands off the parent's real folder on the tier it applies.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct ParentWorkdirReport {
109    #[serde(flatten)]
110    pub snapshot: WorkdirSnapshot,
111    /// The child's own filesystem restriction, from its applied tier: `absent` where the parent's
112    /// folder is not in its composed root, `enforced` where Landlock refuses every open of it,
113    /// `advisory` where nothing in the kernel does.
114    pub restriction: FilesystemRestriction,
115}
116
117impl ParentWorkdirReport {
118    /// The restriction in words, for `--explain-scope`'s `Parent work folder` block.
119    #[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/// Why a copy stopped. Either way the destination holds a partial copy the caller removes.
141#[derive(Debug, thiserror::Error)]
142pub enum SnapshotError {
143    /// The copied bytes would pass `max_bytes`.
144    #[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    /// A read of the source's root or any write of the destination failed.
150    #[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/// What [`copy_workdir`] is told beyond its two roots.
159#[derive(Debug, Clone, Default)]
160pub struct SnapshotLimits {
161    /// The child's effective `capabilities.resources.workdir_max_bytes`.
162    pub max_bytes: u64,
163    /// `(st_dev, st_ino)` of directories never entered: the parent session's runtime and records
164    /// directories. The destination's own identity is always added.
165    pub excluded: Vec<(u64, u64)>,
166}
167
168/// Copies `source` into the empty `destination` by [the walk](self#the-walk), bounded by
169/// `limits.max_bytes`, and reports what it copied.
170///
171/// Opens nothing in `source` for writing. `destination`'s own mode and times are left as its
172/// creator set them.
173pub 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
220/// The forms of `source` an absolute link target can name it by: as given, and resolved, so a
221/// link written through either form is re-pointed.
222fn 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
246/// `O_NONBLOCK` so a source entry swapped for a FIFO after its `statat` cannot block the open.
247const 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
258/// User, group and other `rwx`: what is kept of a source mode.
259const 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    /// Copies the entries of `source` into `destination`, `rel` being both directories' path
277    /// relative to their roots.
278    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                // Removed since the listing: there is nothing left to copy or to report.
310                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        // One byte past what is left, so a file that grew since its `statat` is caught here
372        // rather than copied past the ceiling.
373        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                // Readable by `statat` and open, unreadable by `read`: the parent's own business.
378                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        // Owner-only until its contents are in, so nothing else reads a half-copied directory and
429        // a source directory without owner write still receives its entries.
430        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    /// `target` re-pointed into the destination when it is an absolute path inside the source,
480    /// else `None`.
481    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
496/// The names in the directory `dir`, sorted, `.` and `..` left out.
497fn 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
510/// Whether a failed copy failed reading the source rather than writing the destination: `EACCES`
511/// and `EPERM` are a source the parent's runtime may not read, and skip the file; `ENOSPC`, `EIO`
512/// and the rest stop the copy.
513fn 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        // `code.rs`, and the records directory's `trace.jsonl`: only the runtime directory is
772        // excluded here, so the walk reaches the destination and has to skip it by itself.
773        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        // The copy of a read-only directory is still removable by its owner's runtime.
889        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}