Skip to main content

qk_executor/
overlay.rs

1//! Running a command in its own user and mount namespace over an overlay of
2//! the workspace: what it writes there lands in an upper layer of its own,
3//! which the sandbox reads when the task ends.
4
5use std::ffi::CString;
6use std::os::fd::AsRawFd;
7use std::os::unix::ffi::OsStrExt;
8use std::path::Path;
9
10use anyhow::{Context, Result, bail};
11
12use crate::landlock::{self, Rights};
13
14/// The layers of one slot of commands: `upper` and `work`, the overlay's, and
15/// `real`, where the workspace is bound aside so that it can be the lower
16/// layer.
17pub fn layers(overlay: &crate::Overlay, slot: usize) -> [std::path::PathBuf; 3] {
18    let command = overlay.directory.join(format!("command-{slot}"));
19    ["upper", "work", "real"].map(|name| command.join(name))
20}
21
22fn c_path(path: &Path) -> Result<CString> {
23    CString::new(path.as_os_str().as_bytes()).context("a path holds a NUL byte")
24}
25
26/// What the child does between fork and exec, prepared beforehand: it may
27/// make system calls only.
28pub struct Setup {
29    uid_map: CString,
30    gid_map: CString,
31    workspace: CString,
32    real: CString,
33    options: CString,
34    /// The descriptor the workspace bound aside is held at while the
35    /// overlay is mounted, as `real` may lie in the workspace, in qk's
36    /// state, where the overlay hides it: one reserved here, so that no
37    /// descriptor the child inherits has its number.
38    aside: std::os::fd::OwnedFd,
39    /// Each path bound through, relative to the workspace, ending `/.` so
40    /// that only a directory resolves: opened through no link both in the
41    /// workspace bound aside, through `aside`, and in the overlay, so that a
42    /// link put in its place since it was chosen is never followed.
43    passthrough: Vec<CString>,
44    cwd: CString,
45    /// The rights the Landlock rule set handles, the descriptors opened
46    /// outside with their rights, the workspace's rights, and each path in
47    /// the workspace, relative to it, with its rights and whether to reach
48    /// it through no link.
49    handled: Rights,
50    outside: Vec<(i32, Rights)>,
51    workspace_rights: Rights,
52    inside: Vec<(CString, Rights, bool)>,
53}
54
55impl Setup {
56    pub fn new(overlay: &crate::Overlay, slot: usize, cwd: &Path) -> Result<Self> {
57        let [upper, work, real] = layers(overlay, slot);
58        for directory in [&upper, &work, &real] {
59            std::fs::create_dir_all(directory)
60                .with_context(|| format!("cannot create {}", directory.display()))?;
61        }
62        let text = |path: &Path| -> Result<String> {
63            let text = path.to_str().context("an overlay's paths must be UTF-8")?;
64            // The mount options separate with `,` and lower layers with `:`.
65            if text.contains([',', ':', '\\']) {
66                bail!("an overlay cannot be mounted at {text}");
67            }
68            Ok(text.to_owned())
69        };
70        let options = format!(
71            "lowerdir={},upperdir={},workdir={},userxattr",
72            text(&real)?,
73            text(&upper)?,
74            text(&work)?
75        );
76        // The same user and group inside as outside, so files keep their
77        // owner.
78        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
79        let aside: std::os::fd::OwnedFd = std::fs::File::open("/dev/null")
80            .context("cannot reserve a descriptor")?
81            .into();
82        let mut passthrough = Vec::new();
83        for path in &overlay.passthrough {
84            let relative = path
85                .strip_prefix(&overlay.workspace)
86                .context("a path bound through lies outside the workspace")?;
87            passthrough.push(
88                landlock::Open::Directory
89                    .name(relative)
90                    .context("a path holds a NUL byte")?,
91            );
92        }
93        let rules = &overlay.rules;
94        let mut inside = Vec::new();
95        for (path, rights, open) in &rules.inside {
96            let Ok(relative) = path.strip_prefix(&overlay.workspace) else {
97                continue;
98            };
99            let relative = if relative.as_os_str().is_empty() {
100                Path::new(".")
101            } else {
102                relative
103            };
104            let name = open.name(relative).context("a path holds a NUL byte")?;
105            inside.push((name, *rights, *open != landlock::Open::Following));
106        }
107        Ok(Self {
108            aside,
109            handled: rules.handled,
110            outside: rules
111                .outside
112                .iter()
113                .map(|(fd, rights)| (fd.as_raw_fd(), *rights))
114                .collect(),
115            workspace_rights: rules.workspace,
116            inside,
117            uid_map: CString::new(format!("{uid} {uid} 1"))?,
118            gid_map: CString::new(format!("{gid} {gid} 1"))?,
119            workspace: c_path(&overlay.workspace)?,
120            real: c_path(&real)?,
121            options: CString::new(options)?,
122            passthrough,
123            cwd: c_path(cwd)?,
124        })
125    }
126
127    /// Enters the namespaces and mounts the overlay. Between fork and exec:
128    /// system calls only, no allocation.
129    pub fn enter(&self) -> std::io::Result<()> {
130        let fail = || Err(std::io::Error::last_os_error());
131        unsafe {
132            if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
133                return fail();
134            }
135            for (file, text) in [
136                (c"/proc/self/setgroups", c"deny"),
137                (c"/proc/self/uid_map", self.uid_map.as_c_str()),
138                (c"/proc/self/gid_map", self.gid_map.as_c_str()),
139            ] {
140                let fd = libc::open(file.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
141                if fd < 0 {
142                    return fail();
143                }
144                let bytes = text.to_bytes();
145                let written = libc::write(fd, bytes.as_ptr().cast(), bytes.len());
146                libc::close(fd);
147                if written < 0 {
148                    return fail();
149                }
150            }
151            let none = std::ptr::null();
152            if libc::mount(
153                none,
154                c"/".as_ptr(),
155                none,
156                libc::MS_REC | libc::MS_PRIVATE,
157                none.cast(),
158            ) != 0
159                || libc::mount(
160                    self.workspace.as_ptr(),
161                    self.real.as_ptr(),
162                    none,
163                    libc::MS_BIND | libc::MS_REC,
164                    none.cast(),
165                ) != 0
166            {
167                return fail();
168            }
169            let aside = libc::open(
170                self.real.as_ptr(),
171                libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
172            );
173            // In place of the reservation, which `aside` cannot be.
174            let held = self.aside.as_raw_fd();
175            if aside < 0 || libc::dup3(aside, held, libc::O_CLOEXEC) < 0 {
176                return fail();
177            }
178            libc::close(aside);
179            if libc::mount(
180                c"overlay".as_ptr(),
181                self.workspace.as_ptr(),
182                c"overlay".as_ptr(),
183                0,
184                self.options.as_ptr().cast(),
185            ) != 0
186            {
187                return fail();
188            }
189            let merged = libc::open(
190                self.workspace.as_ptr(),
191                libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
192            );
193            if merged < 0 {
194                return fail();
195            }
196            for relative in &self.passthrough {
197                // Each end through no link, as a directory: one where a link
198                // or anything else now stands, or that is gone, is not bound,
199                // and what the command writes there lands in the overlay.
200                let ends = landlock::open_beneath(held, relative)
201                    .and_then(|source| Ok((source, landlock::open_beneath(merged, relative)?)));
202                let (source, target) = match ends {
203                    Ok(ends) => ends,
204                    Err(error)
205                        if matches!(
206                            error.raw_os_error(),
207                            Some(libc::ENOENT | libc::ELOOP | libc::EXDEV | libc::ENOTDIR)
208                        ) =>
209                    {
210                        continue;
211                    }
212                    // Where openat2 is refused, as a seccomp filter may, the
213                    // path cannot be bound without following a link that may
214                    // stand in it, so the command fails.
215                    Err(error) => return Err(error),
216                };
217                let (mut source_name, mut target_name) = ([0u8; 32], [0u8; 32]);
218                if libc::mount(
219                    proc_fd(source.as_raw_fd(), &mut source_name).as_ptr(),
220                    proc_fd(target.as_raw_fd(), &mut target_name).as_ptr(),
221                    none,
222                    libc::MS_BIND | libc::MS_REC,
223                    none.cast(),
224                ) != 0
225                {
226                    return fail();
227                }
228            }
229            libc::close(merged);
230            libc::close(held);
231            // The directory it was in is now beneath the overlay.
232            if libc::chdir(self.cwd.as_ptr()) != 0 {
233                return fail();
234            }
235            self.restrict()?;
236        }
237        Ok(())
238    }
239
240    /// Builds the Landlock rule set over the mounted overlay and restricts
241    /// the command to it. Between fork and exec: system calls only.
242    unsafe fn restrict(&self) -> std::io::Result<()> {
243        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
244        let ruleset = landlock::create_ruleset(self.handled)?;
245        let add = |fd: i32, rights: Rights| {
246            landlock::add_rule(ruleset.as_raw_fd(), fd, rights, self.handled)
247        };
248        for (fd, rights) in &self.outside {
249            add(*fd, *rights)?;
250        }
251        let root = unsafe {
252            libc::open(
253                self.workspace.as_ptr(),
254                libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
255            )
256        };
257        if root < 0 {
258            return Err(std::io::Error::last_os_error());
259        }
260        // SAFETY: open returned a new descriptor, owned here.
261        let root = unsafe { OwnedFd::from_raw_fd(root) };
262        add(root.as_raw_fd(), self.workspace_rights)?;
263        for (relative, rights, unlinked) in &self.inside {
264            let opened = if *unlinked {
265                landlock::open_beneath(root.as_raw_fd(), relative)
266            } else {
267                let fd = unsafe {
268                    libc::openat(
269                        root.as_raw_fd(),
270                        relative.as_ptr(),
271                        libc::O_PATH | libc::O_CLOEXEC,
272                    )
273                };
274                if fd < 0 {
275                    Err(std::io::Error::last_os_error())
276                } else {
277                    // SAFETY: openat returned a new descriptor, owned here.
278                    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
279                }
280            };
281            let fd = match opened {
282                Ok(fd) => fd,
283                // One that does not exist, or that a link now stands in, is
284                // not allowed, as outside the overlay.
285                Err(error)
286                    if matches!(
287                        error.raw_os_error(),
288                        Some(libc::ENOENT | libc::ELOOP | libc::EXDEV | libc::ENOTDIR)
289                    ) =>
290                {
291                    continue;
292                }
293                Err(error) => return Err(error),
294            };
295            add(fd.as_raw_fd(), *rights)?;
296        }
297        drop(root);
298        landlock::restrict_self(ruleset.as_raw_fd())
299    }
300}
301
302/// `/proc/self/fd/<fd>`, written into `buffer`: the path the descriptor was
303/// opened at, to mount from or on. Between fork and exec, so allocating
304/// nothing.
305fn proc_fd(fd: i32, buffer: &mut [u8; 32]) -> &std::ffi::CStr {
306    const PREFIX: &[u8] = b"/proc/self/fd/";
307    buffer[..PREFIX.len()].copy_from_slice(PREFIX);
308    let mut digits = [0u8; 10];
309    let mut count = 0;
310    let mut rest = fd.unsigned_abs();
311    loop {
312        digits[count] = b'0' + (rest % 10) as u8;
313        count += 1;
314        rest /= 10;
315        if rest == 0 {
316            break;
317        }
318    }
319    for (index, digit) in digits[..count].iter().rev().enumerate() {
320        buffer[PREFIX.len() + index] = *digit;
321    }
322    buffer[PREFIX.len() + count] = 0;
323    std::ffi::CStr::from_bytes_until_nul(&buffer[..]).unwrap_or(c"/proc/self/fd")
324}
325
326/// Whether a command can run over an overlay here: in a user and mount
327/// namespace of its own, with an overlay mounted on a directory in
328/// `directory`, which lies on the workspace's file system. The reason it
329/// cannot, if not.
330pub fn probe(directory: &Path) -> Result<()> {
331    use std::os::unix::process::CommandExt;
332    let [lower, upper, work, merged] =
333        ["lower", "upper", "work", "merged"].map(|name| directory.join(name));
334    for path in [&lower, &upper, &work, &merged] {
335        std::fs::create_dir_all(path)
336            .with_context(|| format!("cannot create {}", path.display()))?;
337    }
338    let options = CString::new(format!(
339        "lowerdir={},upperdir={},workdir={},userxattr",
340        lower.display(),
341        upper.display(),
342        work.display()
343    ))?;
344    let merged_path = c_path(&merged)?;
345    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
346    let uid_map = CString::new(format!("{uid} {uid} 1"))?;
347    let gid_map = CString::new(format!("{gid} {gid} 1"))?;
348    // Which step failed, as the child can only return an error code: written
349    // to a pipe the parent reads.
350    let mut pipe = [0i32; 2];
351    if unsafe { libc::pipe2(pipe.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
352        return Err(std::io::Error::last_os_error()).context("cannot make a pipe");
353    }
354    let (reader, writer) = (pipe[0], pipe[1]);
355    let mut command = std::process::Command::new("/bin/sh");
356    command
357        .args(["-c", "exit 0"])
358        .stdin(std::process::Stdio::null())
359        .stdout(std::process::Stdio::null())
360        .stderr(std::process::Stdio::null());
361    unsafe {
362        command.pre_exec(move || {
363            let fail = |step: u8| {
364                let error = std::io::Error::last_os_error();
365                libc::write(writer, (&step as *const u8).cast(), 1);
366                Err(error)
367            };
368            if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
369                return fail(0);
370            }
371            for (step, (file, text)) in [
372                (c"/proc/self/setgroups", c"deny"),
373                (c"/proc/self/uid_map", uid_map.as_c_str()),
374                (c"/proc/self/gid_map", gid_map.as_c_str()),
375            ]
376            .into_iter()
377            .enumerate()
378            {
379                let fd = libc::open(file.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
380                if fd < 0 {
381                    return fail(1 + step as u8);
382                }
383                let bytes = text.to_bytes();
384                let written = libc::write(fd, bytes.as_ptr().cast(), bytes.len());
385                libc::close(fd);
386                if written < 0 {
387                    return fail(1 + step as u8);
388                }
389            }
390            if libc::mount(
391                c"overlay".as_ptr(),
392                merged_path.as_ptr(),
393                c"overlay".as_ptr(),
394                0,
395                options.as_ptr().cast(),
396            ) != 0
397            {
398                return fail(4);
399            }
400            Ok(())
401        });
402    }
403    let output = command.output();
404    unsafe { libc::close(writer) };
405    let mut step = [u8::MAX];
406    let read = unsafe { libc::read(reader, step.as_mut_ptr().cast(), 1) };
407    unsafe { libc::close(reader) };
408    clear(directory);
409    let what = match (read, step[0]) {
410        (1, 0) => "making a user and mount namespace",
411        (1, 1) => "denying setgroups",
412        (1, 2) => "mapping the user",
413        (1, 3) => "mapping the group",
414        (1, 4) => "mounting an overlay",
415        _ => "running a command over an overlay",
416    };
417    let where_ = directory.display();
418    match output {
419        Ok(output) if output.status.success() => Ok(()),
420        Ok(output) => bail!("{what} in {where_} failed: {}", output.status),
421        Err(error) => Err(error).with_context(|| format!("{what} in {where_} failed")),
422    }
423}
424
425/// Removes an overlay's directory, whose work directory overlayfs leaves
426/// unreadable.
427pub fn clear(directory: &Path) {
428    use std::os::unix::fs::PermissionsExt;
429    let mut stack = vec![directory.to_owned()];
430    while let Some(path) = stack.pop() {
431        if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
432            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700));
433            if let Ok(entries) = std::fs::read_dir(&path) {
434                stack.extend(entries.flatten().map(|entry| entry.path()));
435            }
436        }
437    }
438    let _ = std::fs::remove_dir_all(directory);
439}
440
441/// Applies to the workspace what each command's upper layer holds within
442/// the overlay's `keep`, entry by entry. Entry by entry, since an overlay
443/// another task has mounted would keep seeing a directory replaced whole as
444/// it was.
445pub fn settle(overlay: &crate::Overlay) -> crate::Settled {
446    let mut settled = crate::Settled::default();
447    for slot in 0.. {
448        let [upper, ..] = layers(overlay, slot);
449        if !upper.parent().is_some_and(Path::exists) {
450            break;
451        }
452        if upper.is_dir() {
453            let layer = Layer {
454                upper: &upper,
455                root: &overlay.workspace,
456                keep: &overlay.keep,
457                passthrough: &overlay.passthrough,
458            };
459            layer.apply(Path::new(""), false, &mut settled);
460        }
461    }
462    settled
463}
464
465/// One command's upper layer, applied to the workspace at `root`.
466struct Layer<'a> {
467    upper: &'a Path,
468    root: &'a Path,
469    /// Where the task may write.
470    keep: &'a crate::Keep,
471    /// Bound through the overlay: what lies there the layer never holds.
472    passthrough: &'a [std::path::PathBuf],
473}
474
475impl Layer<'_> {
476    fn allowed(&self, target: &Path) -> bool {
477        self.keep.allows(self.root, target)
478    }
479
480    /// Whether something bound through the overlay lies beneath `target`.
481    fn holds_bound(&self, target: &Path) -> bool {
482        self.passthrough
483            .iter()
484            .any(|through| through.starts_with(target))
485    }
486
487    /// Whether something the task may write lies beneath `target`.
488    fn holds_kept(&self, target: &Path) -> bool {
489        self.keep.holds(target)
490    }
491
492    /// Whether `relative`, a directory made in the workspace, is one `keep`
493    /// names by name, beneath none other.
494    fn names(&self, relative: &Path) -> bool {
495        let named = |part: &std::ffi::OsStr| self.keep.names.iter().any(|name| part == name);
496        relative.file_name().is_some_and(named)
497            && relative
498                .parent()
499                .is_none_or(|parent| !parent.components().any(|part| named(part.as_os_str())))
500    }
501
502    /// Applies what the layer holds under `relative`, adding what it may
503    /// not write to `settled.discarded` and what could not be applied to
504    /// `settled.failed`. A whiteout removes; an opaque directory, or one
505    /// beneath it, which hides what the workspace holds there as `hidden`
506    /// says, replaces what was there; anything else is moved into place.
507    /// Nothing is reached through a symbolic link in the workspace: a command
508    /// could leave one in an output pointing out of it, which a later
509    /// command's directory would be applied through. A link where the layer
510    /// holds a directory is replaced by it, as the command did, or, outside
511    /// what it may write, keeps the directory's entries out. `relative`
512    /// itself holds no link: each directory is checked before what it holds
513    /// is applied. Returns whether it applied anything.
514    fn apply(&self, relative: &Path, hidden: bool, settled: &mut crate::Settled) -> bool {
515        use std::os::unix::fs::{FileTypeExt, MetadataExt};
516        let mut applied = hidden && self.prune(relative, settled);
517        let entries = match std::fs::read_dir(self.upper.join(relative)) {
518            Ok(entries) => entries,
519            Err(error) => {
520                settled.fail(&self.upper.join(relative), &error);
521                return applied;
522            }
523        };
524        for entry in entries {
525            let entry = match entry {
526                Ok(entry) => entry,
527                Err(error) => {
528                    settled.fail(&self.upper.join(relative), &error);
529                    continue;
530                }
531            };
532            let relative = relative.join(entry.file_name());
533            let source = self.upper.join(&relative);
534            let target = self.root.join(&relative);
535            let metadata = match std::fs::symlink_metadata(&source) {
536                Ok(metadata) => metadata,
537                Err(error) => {
538                    settled.fail(&source, &error);
539                    continue;
540                }
541            };
542            let name = relative.to_string_lossy().into_owned();
543            let kind = metadata.file_type();
544            let existing = std::fs::symlink_metadata(&target).ok();
545            let is_dir = existing.as_ref().is_some_and(|existing| existing.is_dir());
546            let is_link = existing
547                .as_ref()
548                .is_some_and(|existing| existing.is_symlink());
549            if kind.is_char_device() && metadata.rdev() == 0 {
550                // A whiteout.
551                if self.allowed(&target) {
552                    settled.removed(&target);
553                    applied = true;
554                } else if is_dir && self.holds_kept(&target) {
555                    // A directory above outputs is gone, and so is what it
556                    // held of them.
557                    applied |= self.prune(&relative, settled);
558                } else {
559                    settled.discarded.insert(name);
560                }
561            } else if kind.is_dir() {
562                let replaces = hidden || opaque(&source);
563                if self.allowed(&target) {
564                    // A file or link in its place goes, as the command
565                    // removed it; a directory only when the layer's replaces
566                    // it whole. What is bound through beneath it stays.
567                    let prunes = is_dir && replaces && self.holds_bound(&target);
568                    if existing.is_some() && (!is_dir || replaces) && !prunes {
569                        settled.removed(&target);
570                    }
571                    // What lies above holds no link, so neither does what
572                    // this makes.
573                    let made = std::fs::create_dir_all(&target).and_then(|()| {
574                        match std::fs::symlink_metadata(&target)?.is_dir() {
575                            true => Ok(()),
576                            false => Err(std::io::Error::other("not a directory")),
577                        }
578                    });
579                    match made {
580                        Ok(()) => {
581                            if !is_dir && self.names(&relative) {
582                                settled.made.push(target.clone());
583                            }
584                            self.apply(&relative, prunes, settled);
585                            // After its entries, which a read-only mode
586                            // would keep out.
587                            let _ = std::fs::set_permissions(&target, metadata.permissions());
588                            applied = true;
589                        }
590                        Err(error) => settled.fail(&target, &error),
591                    }
592                } else if is_link {
593                    settled.discarded.insert(name);
594                } else if self.apply(&relative, replaces && is_dir, settled) {
595                    applied = true;
596                } else if !is_dir {
597                    // A directory it made outside its outputs, holding
598                    // nothing of them.
599                    settled.discarded.insert(name);
600                }
601            } else if self.allowed(&target) {
602                if let Some(parent) = target.parent() {
603                    let _ = std::fs::create_dir_all(parent);
604                }
605                if is_dir {
606                    settled.removed(&target);
607                }
608                // A rename replaces a link at the target rather than follow
609                // it.
610                if std::fs::rename(&source, &target).is_err() {
611                    let _ = remove(&target);
612                    // Copied as it is: a link the command made is never
613                    // followed by qk, which Landlock does not hold, to a
614                    // file the command could not read.
615                    let copied = if kind.is_symlink() {
616                        std::fs::read_link(&source)
617                            .and_then(|link| std::os::unix::fs::symlink(link, &target))
618                    } else if kind.is_file() {
619                        std::fs::copy(&source, &target).map(|_| ())
620                    } else {
621                        settled.discarded.insert(name);
622                        continue;
623                    };
624                    if let Err(error) = copied {
625                        settled.fail(&target, &error);
626                        continue;
627                    }
628                }
629                applied = true;
630            } else {
631                settled.discarded.insert(name);
632            }
633        }
634        applied
635    }
636
637    /// Beneath a directory the layer replaces whole or removes: removes what
638    /// the workspace holds under `relative` and the layer does not, where the
639    /// task may write, and adds the rest to `settled.discarded`. What is
640    /// bound through the overlay stays: the layer never holds it.
641    fn prune(&self, relative: &Path, settled: &mut crate::Settled) -> bool {
642        let entries = match std::fs::read_dir(self.root.join(relative)) {
643            Ok(entries) => entries,
644            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
645            Err(error) => {
646                settled.fail(&self.root.join(relative), &error);
647                return false;
648            }
649        };
650        let mut removed = false;
651        for entry in entries {
652            let entry = match entry {
653                Ok(entry) => entry,
654                Err(error) => {
655                    settled.fail(&self.root.join(relative), &error);
656                    continue;
657                }
658            };
659            let relative = relative.join(entry.file_name());
660            if std::fs::symlink_metadata(self.upper.join(&relative)).is_ok() {
661                continue;
662            }
663            let target = self.root.join(&relative);
664            if self
665                .passthrough
666                .iter()
667                .any(|through| target.starts_with(through))
668            {
669                continue;
670            }
671            let real_dir = entry.file_type().is_ok_and(|kind| kind.is_dir());
672            if real_dir
673                && (self.holds_bound(&target)
674                    || (!self.allowed(&target) && self.holds_kept(&target)))
675            {
676                removed |= self.prune(&relative, settled);
677            } else if self.allowed(&target) {
678                settled.removed(&target);
679                removed = true;
680            } else {
681                settled
682                    .discarded
683                    .insert(relative.to_string_lossy().into_owned());
684            }
685        }
686        removed
687    }
688}
689
690impl crate::Settled {
691    fn fail(&mut self, path: &Path, error: &std::io::Error) {
692        self.failed.push(format!("{}: {error}", path.display()));
693    }
694
695    /// Removes `target`, noting a failure unless it is already gone.
696    fn removed(&mut self, target: &Path) {
697        if let Err(error) = remove(target)
698            && error.kind() != std::io::ErrorKind::NotFound
699        {
700            self.fail(target, &error);
701        }
702    }
703}
704
705/// Removes a file, a link itself, or a directory with what it holds,
706/// following no link.
707fn remove(target: &Path) -> std::io::Result<()> {
708    match std::fs::symlink_metadata(target)? {
709        metadata if metadata.is_dir() => std::fs::remove_dir_all(target),
710        _ => std::fs::remove_file(target),
711    }
712}
713
714/// Whether overlayfs marked a directory of its upper layer opaque: it hides
715/// what the lower layer holds there.
716fn opaque(directory: &Path) -> bool {
717    let Ok(path) = c_path(directory) else {
718        return false;
719    };
720    let mut value = [0u8; 2];
721    let length = unsafe {
722        libc::lgetxattr(
723            path.as_ptr(),
724            c"user.overlay.opaque".as_ptr(),
725            value.as_mut_ptr().cast(),
726            value.len(),
727        )
728    };
729    length == 1 && value[0] == b'y'
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    /// A file in `upper` at `relative`, with what lies above it.
737    fn write(upper: &Path, relative: &str) {
738        let path = upper.join(relative);
739        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
740        std::fs::write(path, relative).unwrap();
741    }
742
743    #[test]
744    fn writes_a_descriptors_path_without_allocating() {
745        let mut buffer = [0u8; 32];
746        assert_eq!(proc_fd(7, &mut buffer), c"/proc/self/fd/7");
747        assert_eq!(
748            proc_fd(1234567890, &mut buffer),
749            c"/proc/self/fd/1234567890"
750        );
751    }
752
753    /// A directory chosen to be bound through that a link replaced before a
754    /// command entered its overlay is not bound: the link is not followed,
755    /// and what the command writes there lands in its upper layer. One still
756    /// a directory is bound, and the write reaches the workspace.
757    #[test]
758    fn binds_no_link_put_in_place_of_a_directory_bound_through() {
759        let temp = tempfile::tempdir().unwrap();
760        let root = temp.path().canonicalize().unwrap();
761        if let Err(error) = probe(&root.join("probe")) {
762            eprintln!("skipped: an overlay cannot be mounted here: {error:#}");
763            return;
764        }
765        let workspace = root.join("workspace");
766        for directory in ["swapped", "kept", "secret"] {
767            std::fs::create_dir_all(workspace.join(directory)).unwrap();
768        }
769        let Ok(abi) = landlock::abi() else {
770            eprintln!("skipped: Landlock is not available");
771            return;
772        };
773        let everything: std::os::fd::OwnedFd = std::fs::File::open("/").unwrap().into();
774        let overlay = crate::Overlay {
775            workspace: workspace.clone(),
776            directory: root.join("overlay"),
777            passthrough: vec![workspace.join("swapped"), workspace.join("kept")],
778            rules: crate::OverlayRules {
779                handled: Rights::handled(abi),
780                outside: vec![(everything, Rights::ALL)],
781                workspace: Rights::ALL,
782                inside: Vec::new(),
783            },
784            keep: crate::Keep::default(),
785            settled: std::sync::OnceLock::new(),
786        };
787        let setup = Setup::new(&overlay, 0, &workspace).unwrap();
788        // Chosen as a directory, then replaced by a link before the command.
789        std::fs::remove_dir(workspace.join("swapped")).unwrap();
790        std::os::unix::fs::symlink("secret", workspace.join("swapped")).unwrap();
791        let mut command = std::process::Command::new("/bin/sh");
792        command
793            .args(["-c", "echo x > swapped/written && echo x > kept/written"])
794            .current_dir(&workspace);
795        unsafe {
796            std::os::unix::process::CommandExt::pre_exec(&mut command, move || setup.enter());
797        }
798        let status = command.status().unwrap();
799        assert!(status.success());
800        let [upper, ..] = layers(&overlay, 0);
801        assert!(!workspace.join("secret/written").exists());
802        assert!(upper.join("secret/written").is_file());
803        assert!(workspace.join("kept/written").is_file());
804        clear(&overlay.directory);
805    }
806
807    /// What a command wrote beneath a `node_modules`, there before or made by
808    /// it, is applied, removals and replacements too; the rest is not.
809    #[test]
810    fn keeps_what_is_written_in_any_node_modules() {
811        let temp = tempfile::tempdir().unwrap();
812        let workspace = temp.path().join("workspace");
813        for file in [
814            "lib/node_modules/stale/a",
815            "app/node_modules/old/a",
816            "filed/node_modules",
817            "stubbed",
818            "elsewhere/kept",
819        ] {
820            write(&workspace, file);
821        }
822        std::fs::create_dir_all(workspace.join("linked")).unwrap();
823        std::os::unix::fs::symlink(workspace.join("lib"), workspace.join("linked/node_modules"))
824            .unwrap();
825        std::os::unix::fs::symlink(workspace.join("elsewhere"), workspace.join("tied")).unwrap();
826        let overlay = crate::Overlay {
827            workspace: workspace.clone(),
828            directory: temp.path().join("overlay"),
829            passthrough: Vec::new(),
830            rules: crate::OverlayRules::default(),
831            keep: crate::Keep {
832                paths: vec![workspace.join("dist")],
833                names: vec!["node_modules".into()],
834            },
835            settled: std::sync::OnceLock::new(),
836        };
837        let [upper, ..] = layers(&overlay, 0);
838        // A `node_modules` made where none was, and in place of a file and
839        // of a link.
840        for file in [
841            "pkg/node_modules/x/a",
842            "pkg/stray.js",
843            "dist/out.js",
844            "filed/node_modules/x/a",
845            "linked/node_modules/x/a",
846            // Beneath a file, and beneath a link.
847            "stubbed/node_modules/x/a",
848            "tied/node_modules/x/a",
849        ] {
850            write(&upper, file);
851        }
852        // A whiteout, and an opaque directory, where the file system lets
853        // them be made here as overlayfs makes them.
854        std::fs::create_dir_all(upper.join("app/node_modules")).unwrap();
855        let whiteout = c_path(&upper.join("app/node_modules/old")).unwrap();
856        let whited = unsafe { libc::mknod(whiteout.as_ptr(), libc::S_IFCHR, 0) } == 0;
857        write(&upper, "lib/node_modules/fresh/a");
858        let lib = c_path(&upper.join("lib/node_modules")).unwrap();
859        let opaque = unsafe {
860            libc::lsetxattr(
861                lib.as_ptr(),
862                c"user.overlay.opaque".as_ptr(),
863                b"y".as_ptr().cast(),
864                1,
865                0,
866            )
867        } == 0;
868        let settled = overlay.settle();
869        // Beneath a file it cannot be made, and fails; beneath a link, which
870        // is never followed, the link's entry is discarded whole.
871        assert_eq!(settled.failed.len(), 1, "{:?}", settled.failed);
872        assert!(
873            settled.failed[0].contains("stubbed/node_modules"),
874            "{:?}",
875            settled.failed
876        );
877        assert_eq!(
878            settled.discarded,
879            std::collections::BTreeSet::from(
880                ["pkg/stray.js", "stubbed", "tied"].map(str::to_owned)
881            )
882        );
883        assert!(workspace.join("stubbed").is_file());
884        assert!(!workspace.join("elsewhere/node_modules").exists());
885        let mut made = settled.made.clone();
886        made.sort();
887        assert_eq!(
888            made,
889            [
890                "filed/node_modules",
891                "linked/node_modules",
892                "pkg/node_modules"
893            ]
894            .map(|path| workspace.join(path))
895        );
896        // The link is replaced, not followed.
897        assert!(!workspace.join("lib/x").exists());
898        for file in [
899            "pkg/node_modules/x/a",
900            "dist/out.js",
901            "lib/node_modules/fresh/a",
902            "filed/node_modules/x/a",
903            "linked/node_modules/x/a",
904        ] {
905            assert!(workspace.join(file).is_file(), "{file}");
906        }
907        assert!(!workspace.join("pkg/stray.js").exists());
908        if whited {
909            assert!(!workspace.join("app/node_modules/old").exists());
910        } else {
911            eprintln!("skipped: a whiteout cannot be made here");
912        }
913        if opaque {
914            assert!(!workspace.join("lib/node_modules/stale").exists());
915        } else {
916            eprintln!("skipped: an opaque directory cannot be marked here");
917        }
918    }
919}