From 47f8bcdd3d33477b19f5cd9d5d29eea160be9ff6 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 11:57:36 -0700 Subject: [PATCH] tests: map the host's mapping tables in the adapter's VMs The runtime adapter's mapping probes wrote their own sources below /tmp in the guest and mapped them through the root themselves, so no layered scenario mapped a host path: every launch shared nothing, and its guest applied an empty table. The probes now inspect what the host's mapping table put beneath the root, and map from the host export. map [NAME=PATH...] reports the export's mount, with its options, and the mode and owner of the directory above it; the mounts below the root, in order; and for each path, its mount options, what it shows, its entries or contents, whether a write is allowed, and what the overlay's upper holds there. map-checks DIR FILE maps the export's directory DIR and file FILE through mappers of the adapter's own, as it mapped its own sources before. map-refused is gone: a failed mapping of the table now latches the root. The layered scenarios share host directories and a file with the root through the launch's --share options: - the table over each image and upper, in the plan's order, whose writes reach the writable host sources alone, and which hides a denied path; - a failed mapping, which blocks Start: the launch stops the VM, which tears the root down and leaves the sources as they were; - a read-only share inside a writable one, shares at and over a tree that the adapter reserves, and a share outside the host directories that it authorizes, which the launch refuses before it spawns the VM. Without shares, the image over the scratch checks that zero mappings do no work: the guest mounts no export, and nothing is mounted below the root. Part of #822. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04632931-537b-49f9-acc9-b12024ad6f3a --- tests/runtime-adapter/README.md | 94 +++-- tests/runtime-adapter/src/worker.rs | 6 +- tests/runtime-adapter/src/worker/mapping.rs | 445 +++++++++++++------- tests/runtime-adapter/src/worker/probes.rs | 10 +- tests/runtime-adapter/tests/vm_layered.rs | 433 ++++++++++++++++--- 5 files changed, 745 insertions(+), 243 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index cabb180d2..1fd5644c8 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -209,27 +209,31 @@ A value that a probe cannot read is `absent` if its file does not exist, and otherwise `unreadable: PROBLEM`. A new check of a root is a new probe in this table. -The probes of `worker::mapping`, none of which takes arguments either, map -beneath the root as an adapter maps the host paths of its sandboxes: through -the root itself, with `nvx::guest::root::Root::map`, which checks the targets -against the container root's reserved set, and through mappers of the -adapter's own, `nvx::guest::mappings::Mapper`, with reserved sets of its -choice. Each writes its sources below `/tmp/runtime-adapter-sources`, on the -guest's tmpfs, in place of the guest's mount of a host's export: a directory -`dir`, with a file `marker` and an empty directory `inner`, a file `file.txt`, -and an empty directory `out`. The links that they plant in the root lead to +The probes of `worker::mapping` inspect the mappings beneath the root, and map +from the host export, as an adapter maps the host paths of its sandboxes. `map` +inspects the mappings that the worker applied from the host's mapping table, +through the root itself, with `nvx::guest::root::Root::map`, which checks their +targets against the container root's reserved set, and the export that they +come from. `map-checks` maps from the export through mappers of the adapter's +own, `nvx::guest::mappings::Mapper`, with reserved sets of its choice, and +tears down what it mapped. The links that it plants in the root lead to `/tmp/runtime-adapter-outside`, an empty directory outside it. | Probe | Facts | | --- | --- | -| `map` | Through the root: zero mappings, `zero`, with the mounts below the root then, `zero.mounts`; then a table, `table`, of `dir` read-only at `/srv/maps/dir`, `dir/inner` read-write inside it, `file.txt` read-only at `/srv/maps/file.txt`, and `out` read-write at `/srv/maps/out`. The mount points below the root, in the mount table's order, `mounts`; for each mapping, `dir`, `inner`, `file`, and `out`, its options, `NAME.options`, whether a write is `allowed` or `refused`, `NAME.write`, what a write reached in its source, `NAME.source`, or what a read found, `NAME.read`; whether the overlay's upper holds the directory and the empty file that the mapper created, `upper.directory` and `upper.file`; and whether the root is latched, `latched` | -| `map-refused` | Through the root: a mapping below the symbolic link `/srv/link`, `through-link`; whether the root is latched then, `latched`; a later mapping, `later`, and whether its target exists, `later.target`; and the mounts below the root, `mounts`, and the entries of the directory outside it, `outside` | -| `map-checks` | Each check through a new mapper: a link at each component of a target, `link.a`, `link.b`, and `link.new`, and to `..`, `link.up`; a magic link of a procfs that it mounts in the root for the checks, `magic.self`, `magic.thread-self`, `magic.root`, `magic.cwd`, and `magic.fd`; a directory onto a file, `kind.directory`, and a file onto a directory, `kind.file`; a mapping onto a file that an earlier one shows, `nested.onto`, and one that would create its target inside it, `nested.create`, whether that reached the source, `nested.source`, and their teardown, `nested.teardown`; the runtime's reserved targets, `reserved.run`, `reserved.run-nvx`, `reserved.time`, and `reserved.time-below`, the container root's, `reserved.proc`, `reserved.sys`, `reserved.dev`, `reserved.agent`, `reserved.machine-id`, and `reserved.etc`, and those of a set of the adapter's, with the tree `/srv/adapter` and the path `/srv/checks/exact`, `reserved.adapter-tree`, `reserved.adapter-ancestor`, `reserved.adapter-path`, and `reserved.adapter-below`, and whether a refused one created anything, `reserved.created`; a read-only mapping attached and unmounted 200 times while another thread tries to open its file for writing, `read-only.attaches`, `read-only.flags`, `read-only.writable`, and `read-only.source`; and the entries and mounts of the directory outside the root, `outside.entries` and `outside.mounts`, and the mounts that the checks left, `left` | - -A check's outcome is `applied`, or why the mapper refused the mapping: `link` -for a link at the component that the check expects, and otherwise the kind of -the error, such as `reserved` or `target-kind`, with the path that it names, if -any. +| `map [NAME=PATH...]` | The export's mount at `hostfs/root` in the runtime directory: the number of mounts there, `export.count`, and of the topmost, its `export.type` and `export.options`; the mode of `hostfs`, in octal, `hostfs.mode`, and its owner, `hostfs.owner`, as `UID:GID`, or `absent` for both; and the mount points below the root, in the mount table's order, `mounts`. For each NAME, at PATH in the root, such as a mapping's target: the options of the topmost mount there, `NAME.options`, or `none`; what it shows, `NAME.kind`; a directory's entries, by name, in order, separated by commas, `NAME.entries`, or a file's contents, `NAME.read`; whether a write is `allowed` or `refused`, `NAME.write`: of a new file `written`, which holds `runtime-adapter`, in a directory, or of the file, opened for writing; and what the overlay's upper holds at PATH, `NAME.upper`, an empty directory or file where the worker created the target. Finally whether the root is latched, `latched` | +| `map-checks DIR FILE` | From the export's directory DIR, which holds a file `marker` and a directory `inner`, and in which the guest may write, and its file FILE, each check through a new mapper: a link at each component of a target, `link.a`, `link.b`, and `link.new`, and to `..`, `link.up`; a magic link of a procfs that it mounts in the root for the checks, `magic.self`, `magic.thread-self`, `magic.root`, `magic.cwd`, and `magic.fd`; a directory onto a file, `kind.directory`, and a file onto a directory, `kind.file`; a mapping onto a file that an earlier one shows, `nested.onto`, one that would create its target inside it, `nested.create`, and their teardown, `nested.teardown`; the runtime's reserved targets, `reserved.run`, `reserved.run-nvx`, `reserved.time`, and `reserved.time-below`, the container root's, `reserved.proc`, `reserved.sys`, `reserved.dev`, `reserved.agent`, `reserved.machine-id`, and `reserved.etc`, and those of a set of the adapter's, with the tree `/srv/adapter` and the path `/srv/checks/exact`, `reserved.adapter-tree`, `reserved.adapter-ancestor`, `reserved.adapter-path`, and `reserved.adapter-below`, and whether a refused one created anything, `reserved.created`; a read-only mapping of DIR attached and unmounted 200 times while another thread tries to open its `marker` for writing, `read-only.attaches`, `read-only.flags`, and `read-only.writable`; and the entries and mounts of the directory outside the root, `outside.entries` and `outside.mounts`, and the mounts that the checks left, `left` | + +A NAME holds lowercase ASCII letters, digits, and `-`, but is neither `export` +nor `hostfs`, and comes once. PATH is a relative path in the root, and DIR and +FILE are relative paths in the export, such as `2`, its third child, each of +normal components. A kind is `directory`, `file SIZE`, with the file's size in +bytes, `link`, `other`, `denied` for a path that the guest may not look up, +such as one that the export hides, or `absent`. A check's outcome is +`applied`, or why the mapper refused the mapping: `link` for a link at the +component that the check expects, and otherwise the kind of the error, such as +`reserved` or `target-kind`, with the path that it names, if any. The checks +write nothing to the export. ### Executions @@ -1252,14 +1256,14 @@ the host-only configuration, through the host program's commands, and checks that the guest's worker, outside PID 1, assembles the root, or reports the stage that failed, and that the VM still ends cleanly. A launch whose guest assembled the root also follows the host's order: the guest agent is of the -build that the host program ships, applies the empty mapping table, and starts -the sandbox. Each scenario stages its images, then launches them, and is a row -of the table that its `scenarios!` macro declares, and an ignored test of its -own: +build that the host program ships, applies the mapping table, which is empty +unless the scenario shares host paths, and starts the sandbox. Each scenario +stages its images, then launches them, and is a row of the table that its +`scenarios!` macro declares, and an ignored test of its own: | Scenario | Root | Expects | | --- | --- | --- | -| `erofs_over_scratch` | NVX's Ubuntu EROFS image, with its own identity, over a copy of the scratch template | A root that the worker assembled: the image, read-only, under the overlay, whose upper and work directories are on the scratch, each mounted once below the runtime directory, with the options of the default mode, which trusts its lowers; the merged root's contents, with a file that reaches the upper and not the image; a copy-up of a file's metadata alone, which leaves its data in the image, and the rename of a lower directory; the image's and the scratch's disks; the agent's and the workload's cgroups, with their resource values; a worker that is PID 1's child, and PID 1's unrelated child, untouched; and a teardown that unmounts the scratch cleanly | +| `erofs_over_scratch` | NVX's Ubuntu EROFS image, with its own identity, over a copy of the scratch template | A root that the worker assembled: the image, read-only, under the overlay, whose upper and work directories are on the scratch, each mounted once below the runtime directory, with the options of the default mode, which trusts its lowers; the merged root's contents, with a file that reaches the upper and not the image; a copy-up of a file's metadata alone, which leaves its data in the image, and the rename of a lower directory; the image's and the scratch's disks; the agent's and the workload's cgroups, with their resource values; a worker that is PID 1's child, and PID 1's unrelated child, untouched; an empty mapping table, which does no work: the launch exports nothing, the guest mounts no export, and nothing is mounted below the root; and a teardown that unmounts the scratch cleanly | | `erofs_over_scratch_with_untrusted_lowers` | The image over the scratch, in the untrusted-lowers mode, with a journal that the launch allows | The root, whose overlay has none of the trusting mode's options, copies up a file's data too, and refuses the rename of a lower directory with `EXDEV` | | `erofs_over_ram` | The image over a tmpfs in RAM | The root, whose upper and work directories are on a tmpfs of the kernel's default size, whose root directory has mode 0755, with no scratch mounted, and no disk but the image's | | `erofs_over_ram_with_untrusted_lowers` | The image over a tmpfs, in the untrusted-lowers mode | The root over the tmpfs, which copies up a file's data too, and refuses the rename | @@ -1278,8 +1282,15 @@ own: | `recovery_required_layer_fails_the_assembly` | The same cyclic journal with the top layer's `needs_recovery` bit set | The assembly refuses recovery before mounting anything, despite `noload`, and the VM ends cleanly with 14 | | `invalid_configuration_fails_its_stage` | A relative runtime directory | The configuration's failure, which the launch reports, and a VM that ends with 11 | | `busy_root_fails_the_teardown` | The image over the scratch, which the `hold` probe keeps open | The teardown's failure, which the stop reports, and a VM that ends with 15, leaving the scratch mounted | -| `mappings_apply_beneath_the_root` | The image over the scratch, beneath which the `map` and `map-checks` probes map | Through the root: zero mappings that do no work, then a table mounted in order, each mapping with its access, `nosuid`, and `nodev`, whose writes reach the sources of the writable ones only, on targets that the mapper created on the overlay. Through mappers of the adapter's own: a link or a magic link at each component, the wrong kinds, a target that would be created inside another mapping, and the runtime's, the container root's, and the adapter's own reserved targets refused, and a read-only mapping that is never writable. A teardown that unmounts the mappings, then the scratch, cleanly | -| `a_refused_mapping_latches_the_root` | The image over the scratch, in which the `map-refused` probe plants a symbolic link | A mapping through the link that the root refuses, nothing mounted or created, and a root that refuses every later mapping, then tears down cleanly | +| `erofs_over_scratch_maps_the_table` | The image over the scratch, with the mapping table, from whose export the `map-checks` probe also maps | The export, mounted once, read-write, `nosuid`, and `nodev`, below a `hostfs` of mode 0700 that root owns; the table's mappings, mounted in the plan's order, by the depth of their targets, then by their targets, each with its access, `nosuid`, and `nodev`, on targets that the worker created on the overlay, but none inside another mapping; a hidden file that its directory does not list, and whose lookup the host refuses; and writes that reach the host's writable sources alone, the one inside a read-only mapping included. Through mappers of the adapter's own, from the export: a link or a magic link at each component, the wrong kinds, a target that would be created inside another mapping, and the runtime's, the container root's, and the adapter's own reserved targets refused, and a read-only mapping of a directory that the guest may write through the export, which is never writable. A teardown that unmounts the mappings, the export, then the scratch, cleanly | +| `erofs_over_ram_maps_the_table` | The image over a tmpfs, with the mapping table | The table, as over the scratch | +| `gpt_over_scratch_maps_the_table` | The GPT disk over the scratch, with the mapping table | The table, whose targets the worker created with their parents, which the disk's layers do not hold | +| `gpt_over_ram_maps_the_table` | The disk over a tmpfs, with the mapping table | The table, as over the scratch | +| `a_failed_mapping_tears_the_vm_down` | The image over the scratch, with the mapping table and `out` again at `/usr/lib/os-release`, a file of the image, which the plan orders fourth | The guest's refusal of the fourth mapping, a directory onto a file, which the launch reports with the three mappings that the guest applied; no more of the table, and no `Start`; a stop that the guest confirms once it tore the root down, a VM that ends with success and a complete teardown, an exit with 6, no runtime record, and the host's sources unchanged | +| `a_read_only_share_inside_a_writable_one_is_refused_before_launch` | The image over the scratch, with the mapping table and a read-only share inside `out` | A launch that fails before it spawns the VM, since the host could not keep the share read-only, and that leaves nothing of a launch in the store | +| `a_share_inside_a_reserved_tree_is_refused_before_launch` | The image over the scratch, with the mapping table, a tree `/srv/adapter` that the adapter reserves, and a share inside it | A launch that fails before it spawns the VM, and that leaves nothing of a launch in the store | +| `a_share_over_a_reserved_tree_is_refused_before_launch` | The image over the scratch, with the mapping table, the reserved tree, and a share at `/srv`, which would cover it | A launch that fails before it spawns the VM, and that leaves nothing of a launch in the store | +| `an_unauthorized_host_path_is_refused_before_launch` | The image over the scratch, with the mapping table and a share of a host directory beside the sources | A launch that fails before it spawns the VM, since the adapter authorizes the sources alone, and that leaves nothing of a launch in the store | | `root_profiles_enter_the_root` | The image over the scratch, which the `entries` probe enters | Executions that start as PID 1 of their namespaces, with the root moved over `/`, only their standard streams, the leaked descriptor closed, no inheritable or ambient capability, and no new privileges, blocking no signal that their caller blocks, at the top of the root unless they name a working directory. A working directory that the execution names is entered, but none through a magic link, such as the leaked descriptor's, its own working directory's or root's, or any descriptor's, nor through a symbolic link to one: each fails the launch before the program runs. User ID 0 with each allowed capability alone holds it alone, and only its own attempt succeeds; with none, nothing does, nor for a numeric identity without an account. No execution writes `/proc/sys`, so none lowers the unprivileged port start, and a nested `chroot` stays in the root. With published listeners, user ID 0 with every other allowed capability can neither bind a privileged source port nor open a raw socket, so it cannot forge a listener announcement in either publication mode; without them, it may. The entry refuses the 51 capabilities outside its allowlist, the binding and raw-socket ones while listeners are published, capabilities for another user ID, and an identity without an account in existing-account mode. The root's own machine ID, then a generated one and the time status that the guest's time daemon publishes, read-only, in which a root workload finds no lock of the daemon's, which the runtime bound in the helper's mount namespace, and which leave the worker's namespace without mounts below the root. A teardown that unmounts the scratch cleanly | | `unstaged_images_are_refused_before_spawn` | The image over the scratch, which the scenario does not stage | A launch that fails before it spawns the VM, since the store stages no image at its path, and that leaves no launch journal, VMM log, runtime record, or launch record; then, once the scenario stages the images, the root that the worker assembles, and a clean teardown | | `a_changed_image_is_refused_before_spawn` | The GPT disk over the scratch, which the scenario writes in place after it stages it | A launch that fails before it spawns the VM, since the disk's modification time differs from its binding's, and that leaves nothing of a launch in the store; then, once the scenario stages the disk again, the root, and a clean teardown | @@ -1287,15 +1298,32 @@ own: A row names the root, the image, the GPT disk's top-layer variant, the identity that the launch expects of the image, the upper, the runtime -directory, the overlay mode, the scratch's journal policy, and the bound of the -first partition, and what the scenario expects: a root that the guest -assembles, with a check that probes it, a teardown that fails after a check, -the stage that fails, with its problem, or a launch that the host program -refuses before it spawns the VM, with the change that the scenario makes after -it stages the images, and the problem. Each scenario that launches a VM checks -the launch's record, with the bindings that the store stages, which the launch -checked, and, over a scratch, whether the VM left the scratch's journal to -recover, which tells whether the guest unmounted the scratch. +directory, the overlay mode, the scratch's journal policy, the bound of the +first partition, and the host paths that the launch shares, and what the +scenario expects: a root that the guest assembles, with a check that probes +it, a teardown that fails after a check, the stage that fails, with its +problem, a mapping of the table that the guest fails, after how many, with its +problem, shares that the host program refuses before it spawns the VM, with +the problem, or a launch that the host program refuses before it spawns the +VM, with the change that the scenario makes after it stages the images, and +the problem. Each scenario that launches a VM checks the launch's record, with +the bindings that the store stages, which the launch checked, and the number +of mappings that the root configuration announced, and, over a scratch, +whether the VM left the scratch's journal to recover, which tells whether the +guest unmounted the scratch. + +A scenario with the mapping table writes its sources in its directory, below +`sources`: a directory `dir`, with the files `marker` and `secret` and an empty +directory `inner`; a file `file.txt`; and a directory `out`, with a file +`marker` and an empty directory `inner`. The launch shares `dir` read-only at +`/srv/maps/dir`, `dir/inner` read-write inside it, `file.txt` read-only at +`/srv/maps/file.txt`, and `out` read-write at `/srv/maps/out`, hides +`dir/secret` with `--deny`, and authorizes `sources` alone with +`--share-root`. The plan exports `dir`, `file.txt`, and `out` as the export's +children `0`, `1`, and `2`. Once the `map` probe ran, the sources hold what +the scenario wrote, and the file `written` in `dir/inner` and in `out` alone; +the scenarios of a failed mapping and of refused shares leave the sources as +the scenario wrote them. The scenarios use the fixtures of NVX's own layered scenarios, from NVX's artifacts: the Ubuntu EROFS image, `ubuntu-distro.erofs`, its manifest, which diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index 29800a9cd..f6ecf540c 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -750,8 +750,8 @@ impl RootOperations for LibraryRoot { Ok(()) } - /// Runs one of the [`probes`], of the [`mapping`] probes, which map beneath the root, or of the - /// [`entries`] probes, which enter it. + /// Runs one of the [`probes`], of the [`mapping`] probes, which inspect the root's mappings + /// and map from the host export, or of the [`entries`] probes, which enter it. fn probe(&mut self, probe: &Probe) -> Result { let (Some(bootstrap), Some(root)) = (&self.bootstrap, &mut self.root) else { return Err("the worker holds no root to probe".to_owned()); @@ -781,7 +781,7 @@ impl RootOperations for LibraryRoot { sibling: self.sibling, }; if mapping::PROBES.contains(&probe.name()) { - return mapping::run(probe, &context, root); + return mapping::run(probe, &context, root, self.export.as_ref()); } if entries::PROBES.contains(&probe.name()) { return entries::run(probe, &context); diff --git a/tests/runtime-adapter/src/worker/mapping.rs b/tests/runtime-adapter/src/worker/mapping.rs index 13f718a36..d50e3eabd 100644 --- a/tests/runtime-adapter/src/worker/mapping.rs +++ b/tests/runtime-adapter/src/worker/mapping.rs @@ -1,26 +1,33 @@ -//! The probes that map beneath the worker's assembled root, as an adapter maps the host paths of -//! its sandboxes: through the root itself, with `nvx::guest::root::Root::map`, which checks the -//! targets against the container root's reserved set, and through mappers of the adapter's own, -//! `nvx::guest::mappings::Mapper`, with reserved sets of its choice. Each probe writes its -//! sources below `/tmp/runtime-adapter-sources`, on the guest's tmpfs, in place of the guest's -//! mount of a host's export: a directory `dir`, which holds the file `marker` and the empty -//! directory `inner`; a file `file.txt`; and an empty directory `out`. The links that the probes -//! plant in the root lead to `/tmp/runtime-adapter-outside`, an empty directory outside it. +//! The probes of the mappings beneath the worker's assembled root, which come from the host +//! export, as an adapter maps the host paths of its sandboxes: +//! +//! - `map` inspects the mappings that the worker applied from the host's mapping table, through +//! the root itself, with `nvx::guest::root::Root::map`, which checks their targets against the +//! container root's reserved set, and the export that they come from; +//! - `map-checks` maps from the export through mappers of the adapter's own, +//! `nvx::guest::mappings::Mapper`, with reserved sets of its choice, and tears down what it +//! mapped. The links that it plants in the root lead to `/tmp/runtime-adapter-outside`, an empty +//! directory outside it. //! //! | Probe | Facts | //! | --- | --- | -//! | `map` | Through the root, zero mappings, `zero`, and the mounts below the root then, `zero.mounts`; then a table, `table`: `dir` read-only at `/srv/maps/dir`, `dir/inner` read-write at `/srv/maps/dir/inner`, inside the first, `file.txt` read-only at `/srv/maps/file.txt`, and `out` read-write at `/srv/maps/out`. The mount points below the root, in the mount table's order, `mounts`, and of each mapping, `dir`, `inner`, `file`, and `out`, the options, `NAME.options`, and whether a write is `allowed` or `refused`, `NAME.write`, with what a write reached in the source, `NAME.source`, and what a read found, `NAME.read`. Whether the overlay's upper holds the created directory `srv/maps`, `upper.directory`, and the created empty file `srv/maps/file.txt`, `upper.file`; and whether the root is latched, `latched` | -//! | `map-refused` | Through the root, a mapping whose target is below the link `/srv/link`, `through-link`; whether the root is latched then, `latched`; a later mapping at `/srv/later`, `later`, and whether its target exists, `later.target`; the number of mounts below the root, `mounts`, and of entries in the directory outside it, `outside` | -//! | `map-checks` | Through a new mapper for each check: a link at each component of `/srv/checks/tN/a/b/new`, `link.a`, `link.b`, and `link.new`, and a link to `..`, `link.up`; magic links of a procfs that it mounts at `/srv/checks/proc`, then unmounts, `magic.self`, `magic.thread-self`, `magic.root`, `magic.cwd`, and `magic.fd`; a directory onto a file, `kind.directory`, and a file onto a directory, `kind.file`; through one mapper, a mapping onto a file that an earlier one shows, `nested.onto`, one that would create a target inside it, `nested.create`, whether that reached the source, `nested.source`, and its teardown, `nested.teardown`; the runtime's reserved targets, `reserved.run`, `reserved.run-nvx`, `reserved.time`, and `reserved.time-below`, the container root's, `reserved.proc`, `reserved.sys`, `reserved.dev`, `reserved.agent`, `reserved.machine-id`, and `reserved.etc`, and those of a set that the adapter chooses, the tree `/srv/adapter` and the path `/srv/checks/exact`: `reserved.adapter-tree`, `reserved.adapter-ancestor`, `reserved.adapter-path`, and `reserved.adapter-below`, with whether the refused ones created anything, `reserved.created`. A read-only mapping attached and unmounted again and again while another thread tries to open its file for writing: the attaches, `read-only.attaches`, their flags, `read-only.flags`, whether the file was ever writable, `read-only.writable`, and the source's file afterwards, `read-only.source`. Finally the entries and the mounts of the directory outside the root, `outside.entries` and `outside.mounts`, and the mounts that the checks left below `/srv/checks`, `left` | +//! | `map [NAME=PATH...]` | The export's mount at `hostfs/root` in the runtime directory: the number of mounts there, `export.count`, and of the topmost, its `export.type` and `export.options`; the mode of `hostfs`, in octal, `hostfs.mode`, and its owner, `hostfs.owner`, as `UID:GID`, or `absent` for both; and the mount points below the root, in the mount table's order, `mounts`. For each NAME, at PATH in the root, such as a mapping's target: the options of the topmost mount there, `NAME.options`, or `none`; what it shows, `NAME.kind`; a directory's entries, by name, in order, separated by commas, `NAME.entries`, or a file's contents, `NAME.read`; whether a write is `allowed` or `refused`, `NAME.write`: of the new file [`WRITTEN_FILE`], with [`WRITTEN`], in a directory, or of the file, opened for writing; and what the overlay's upper holds at PATH, `NAME.upper`, an empty directory or file where the mapper created the target. Finally whether the root is latched, `latched` | +//! | `map-checks DIR FILE` | From the export's directory DIR, which holds a file `marker` and a directory `inner`, and in which the guest may write, and from its file FILE, through a new mapper for each check: a link at each component of `/srv/checks/tN/a/b/new`, `link.a`, `link.b`, and `link.new`, and a link to `..`, `link.up`; magic links of a procfs that it mounts at `/srv/checks/proc`, then unmounts, `magic.self`, `magic.thread-self`, `magic.root`, `magic.cwd`, and `magic.fd`; a directory onto a file, `kind.directory`, and a file onto a directory, `kind.file`; through one mapper, a mapping onto a file that an earlier one shows, `nested.onto`, one that would create a target inside it, `nested.create`, and its teardown, `nested.teardown`; the runtime's reserved targets, `reserved.run`, `reserved.run-nvx`, `reserved.time`, and `reserved.time-below`, the container root's, `reserved.proc`, `reserved.sys`, `reserved.dev`, `reserved.agent`, `reserved.machine-id`, and `reserved.etc`, and those of a set that the adapter chooses, the tree `/srv/adapter` and the path `/srv/checks/exact`: `reserved.adapter-tree`, `reserved.adapter-ancestor`, `reserved.adapter-path`, and `reserved.adapter-below`, with whether the refused ones created anything, `reserved.created`. A read-only mapping of DIR attached and unmounted again and again while another thread tries to open its `marker` for writing: the attaches, `read-only.attaches`, their flags, `read-only.flags`, and whether the file was ever writable, `read-only.writable`. Finally the entries and the mounts of the directory outside the root, `outside.entries` and `outside.mounts`, and the mounts that the checks left below `/srv/checks`, `left` | //! -//! A check's outcome is `applied`, or why the mapper refused the mapping: `link` for a link at the -//! component that the check expects, and otherwise the kind of the error, such as `reserved` or -//! `target-kind`, with the path that it names, if any. None of the probes takes arguments. - +//! A NAME holds lowercase ASCII letters, digits, and `-`, but is neither `export` nor `hostfs`, and +//! comes once. PATH is a relative path in the root, and DIR and FILE are relative paths in the +//! export, each of normal components. A kind is `directory`, `file SIZE`, with the file's size in +//! bytes, `link`, `other`, `denied` for a path that the guest may not look up, such as one that +//! the export hides, or `absent`. A check's outcome is `applied`, or why the mapper refused +//! the mapping: `link` for a link at the component that the check expects, and otherwise the kind +//! of the error, such as `reserved` or `target-kind`, with the path that it names, if any. The +//! checks write nothing to the export, which the host's own sources show. + +use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::os::fd::AsRawFd; -use std::os::unix::fs::symlink; +use std::os::unix::fs::{MetadataExt, symlink}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, PoisonError, RwLock}; @@ -31,132 +38,195 @@ use nix::mount::{self, MsFlags}; use nvx::guest::mappings::{ Access, InvalidMapping, Mapper, Mapping, MappingError, Reserved, SourceRoot, TargetRoot, }; -use nvx::guest::root::{self, Root}; +use nvx::guest::root::Root; use super::probes::{self, Context}; use crate::probe::{Facts, Probe}; -/// The names of the probes of this module, which the worker runs with its root. -pub const PROBES: [&str; 3] = ["map", "map-refused", "map-checks"]; - -/// The directory below which each probe writes its sources, in a directory of its name. -const SOURCES: &str = "/tmp/runtime-adapter-sources"; -/// The directory outside the root to which the links that the probes plant lead. +/// The names of the probes of this module, which the worker runs with its root and the host +/// export. +pub const PROBES: [&str; 2] = ["map", "map-checks"]; + +/// The mount point of the host export, below the runtime directory, as `nvx::guest::root` names +/// it. +const EXPORT: &str = "hostfs/root"; +/// The directory of the export's mount point, below the runtime directory, which only root may +/// enter. +const HOSTFS: &str = "hostfs"; +/// The names of the facts of the `map` probe that no mapping's name may take. +const GENERAL: [&str; 2] = ["export", "hostfs"]; +/// The directory outside the root to which the links that `map-checks` plants lead. const OUTSIDE: &str = "/tmp/runtime-adapter-outside"; -/// What the probes write through writable mappings. +/// The file that the `map` probe creates in a directory that a mapping shows. +const WRITTEN_FILE: &str = "written"; +/// What the `map` probe writes through writable mappings. const WRITTEN: &str = "runtime-adapter"; /// The number of times that `map-checks` attaches a read-only mapping, and unmounts it again, /// while another thread tries to open its file for writing. const READ_ONLY_ATTACHES: usize = 200; -/// Runs `probe`, one of [`PROBES`], with `context` and the worker's `root`, and returns its facts. +/// Runs `probe`, one of [`PROBES`], with `context`, the worker's `root`, and the root of the host +/// export, `export`, once the root mounted one, and returns its facts. /// -/// Fails with the problem for another probe, one with arguments, and one that fails. -pub fn run(probe: &Probe, context: &Context<'_>, root: &mut Root) -> Result { +/// Fails with the problem for another probe, one with arguments that the +/// [module documentation](self) does not allow, `map-checks` without an export, and one that +/// fails. +pub fn run( + probe: &Probe, + context: &Context<'_>, + root: &Root, + export: Option<&SourceRoot>, +) -> Result { let name = probe.name(); - if !PROBES.contains(&name) { - return Err(format!("the worker runs no probe named {name}")); - } - if !probe.arguments().is_empty() { - return Err(format!("the {name} probe takes no arguments")); - } - let facts = match name { - "map" => map(context, root), - "map-refused" => map_refused(context, root), - _ => map_checks(context), + let facts = match (name, probe.arguments()) { + ("map", arguments) => map(context, root, &mappings(arguments)?), + ("map-checks", [directory, file]) => { + let export = export.ok_or( + "the map-checks probe maps from the host export, which the root has not mounted", + )?; + map_checks(context, export, export_path(directory)?, export_path(file)?) + } + ("map-checks", _) => return Err("the map-checks probe takes two arguments".to_owned()), + _ => return Err(format!("the worker runs no probe named {name}")), }; facts.map_err(|error| format!("the {name} probe failed: {error}")) } -/// Runs the `map` probe. -fn map(context: &Context<'_>, root: &mut Root) -> io::Result { - let (sources, source) = sources("map")?; - let mut facts = Facts::new(); - facts.push("zero", outcome(root.map(&source, &[]))); - facts.push("zero.mounts", mounts_below(context.root)?.len().to_string()); - let table = [ - mapping("dir", "/srv/maps/dir", Access::ReadOnly)?, - mapping("dir/inner", "/srv/maps/dir/inner", Access::ReadWrite)?, - mapping("file.txt", "/srv/maps/file.txt", Access::ReadOnly)?, - mapping("out", "/srv/maps/out", Access::ReadWrite)?, - ]; - facts.push("table", outcome(root.map(&source, &table))); - let mounts = mounts_below(context.root)? - .iter() - .map(|(point, _)| in_root(context.root, point)) - .collect::>(); - facts.push("mounts", mounts.join(",")); - let target = |path: &str| context.root.join(path); - for (name, path) in [ - ("dir", "srv/maps/dir"), - ("inner", "srv/maps/dir/inner"), - ("file", "srv/maps/file.txt"), - ("out", "srv/maps/out"), - ] { - facts.push(format!("{name}.options"), options_at(&target(path))?); +/// Reads the arguments of the `map` probe, each `NAME=PATH`: a name in the facts, and the path that +/// it inspects, a relative path in the root. +/// +/// Fails with the problem for an argument that breaks the rules of the +/// [module documentation](self). +fn mappings(arguments: &[String]) -> Result, String> { + let mut mappings: Vec<(&str, &Path)> = Vec::with_capacity(arguments.len()); + for argument in arguments { + let refused = || { + format!( + "the map probe takes NAME=PATH, with a name of lowercase ASCII letters, digits, \ + and `-`, other than export and hostfs, not {argument:?}" + ) + }; + let (name, path) = argument.split_once('=').ok_or_else(refused)?; + let valid = !name.is_empty() + && !GENERAL.contains(&name) + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if !valid { + return Err(refused()); + } + let path = probes::path_argument("map", path)?; + if mappings.iter().any(|(other, _)| *other == name) { + return Err(format!("the map probe names {name} twice")); + } + mappings.push((name, path)); } - facts.push("dir.read", probes::read(&target("srv/maps/dir/marker"))); - facts.push("dir.write", create(&target("srv/maps/dir/new"))); - facts.push("inner.write", create(&target("srv/maps/dir/inner/new"))); - facts.push("inner.source", probes::read(&sources.join("dir/inner/new"))); - facts.push("file.read", probes::read(&target("srv/maps/file.txt"))); - facts.push("file.write", open_for_writing(&target("srv/maps/file.txt"))); - facts.push("out.write", create(&target("srv/maps/out/new"))); - facts.push("out.source", probes::read(&sources.join("out/new"))); - let upper = context.upper.join("upper/srv/maps"); - facts.push("upper.directory", yes(upper.is_dir())); - let file = fs::symlink_metadata(upper.join("file.txt")); - facts.push( - "upper.file", - yes(file.is_ok_and(|file| file.is_file() && file.len() == 0)), - ); - facts.push("latched", yes(root.is_latched())); - Ok(facts) + Ok(mappings) } -/// Runs the `map-refused` probe. -fn map_refused(context: &Context<'_>, root: &mut Root) -> io::Result { - let (_, source) = sources("map-refused")?; - let outside = outside()?; - symlink(&outside, context.root.join("srv/link"))?; +/// Returns `argument`, a path in the host export, once it is a relative path of normal +/// components, or the problem. +fn export_path(argument: &str) -> Result<&str, String> { + if argument + .split('/') + .all(|component| !matches!(component, "" | "." | "..")) + { + Ok(argument) + } else { + Err(format!( + "the map-checks probe takes relative paths in the host export, not {argument:?}" + )) + } +} + +/// Runs the `map` probe, of `mappings`, each a name and the path that it inspects, a relative path +/// in the root. +fn map(context: &Context<'_>, root: &Root, mappings: &[(&str, &Path)]) -> io::Result { + let table = probes::mount_table(&fs::read_to_string(probes::MOUNTINFO)?)?; + // Of the mounts at a point, the table lists the topmost last. + let topmost = |point: &Path| table.iter().rev().find(|mount| mount.point == point); let mut facts = Facts::new(); - let through = mapping("dir", "/srv/link/escape", Access::ReadOnly)?; - facts.push("through-link", outcome(root.map(&source, &[through]))); + let export = context.runtime.join(EXPORT); + let exports = table.iter().filter(|mount| mount.point == export).count(); + facts.push("export.count", exports.to_string()); + if let Some(mount) = topmost(&export) { + facts.push("export.type", mount.kind.as_str()); + facts.push("export.options", mount.options.as_str()); + } + let (mode, owner) = match fs::symlink_metadata(context.runtime.join(HOSTFS)) { + Ok(metadata) => ( + format!("{:04o}", metadata.mode() & 0o7777), + format!("{}:{}", metadata.uid(), metadata.gid()), + ), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + ("absent".to_owned(), "absent".to_owned()) + } + Err(error) => return Err(error), + }; + facts.push("hostfs.mode", mode); + facts.push("hostfs.owner", owner); + let below = table + .iter() + .filter(|mount| mount.point.starts_with(context.root) && mount.point != context.root) + .map(|mount| in_root(context.root, &mount.point)) + .collect::>(); + facts.push("mounts", below.join(",")); + for (name, path) in mappings { + let target = context.root.join(path); + let options = topmost(&target).map_or("none", |mount| mount.options.as_str()); + facts.push(format!("{name}.options"), options); + let kind = Kind::of(&target)?; + facts.push(format!("{name}.kind"), kind.to_string()); + match kind { + Kind::Directory => { + facts.push(format!("{name}.entries"), entries(&target)); + facts.push(format!("{name}.write"), create(&target.join(WRITTEN_FILE))); + } + Kind::File(_) => { + facts.push(format!("{name}.read"), probes::read(&target)); + facts.push(format!("{name}.write"), open_for_writing(&target)); + } + Kind::Absent | Kind::Link | Kind::Denied | Kind::Other => {} + } + let upper = Kind::of(&context.upper_directory().join(path))?; + facts.push(format!("{name}.upper"), upper.to_string()); + } facts.push("latched", yes(root.is_latched())); - let later = mapping("dir", "/srv/later", Access::ReadOnly)?; - facts.push("later", outcome(root.map(&source, &[later]))); - facts.push("later.target", exists(&context.root.join("srv/later"))); - facts.push("mounts", mounts_below(context.root)?.len().to_string()); - facts.push("outside", fs::read_dir(&outside)?.count().to_string()); Ok(facts) } -/// Runs the `map-checks` probe. -fn map_checks(context: &Context<'_>) -> io::Result { - let (sources, source) = sources("map-checks")?; +/// Runs the `map-checks` probe, from the root of the host export, `export`, with its directory +/// `directory`, and its file `file`. +fn map_checks( + context: &Context<'_>, + export: &SourceRoot, + directory: &str, + file: &str, +) -> io::Result { let outside = outside()?; let base = context.root.join("srv/checks"); fs::create_dir_all(&base)?; let checks = Checks { root: context.root, - source: &source, + source: export, + directory, }; let mut facts = Facts::new(); // A link at each component in turn, to the directory outside the root, and one to `..`. let components = ["a", "b", "new"]; for (index, link) in components.iter().enumerate() { - let mut directory = base.join(format!("t{index}")); - directory.extend(&components[..index]); - fs::create_dir_all(&directory)?; - symlink(&outside, directory.join(link))?; + let mut parent = base.join(format!("t{index}")); + parent.extend(&components[..index]); + fs::create_dir_all(&parent)?; + symlink(&outside, parent.join(link))?; let target = format!("/srv/checks/t{index}/{}", components.join("/")); let linked = format!("/srv/checks/t{index}/{}", components[..=index].join("/")); - let found = checks.one(Reserved::default(), "dir", &target)?; + let found = checks.one(Reserved::default(), directory, &target)?; facts.push(format!("link.{link}"), at_link(found, &linked)); } symlink("..", base.join("up"))?; - let found = checks.one(Reserved::default(), "dir", "/srv/checks/up/new")?; + let found = checks.one(Reserved::default(), directory, "/srv/checks/up/new")?; facts.push("link.up", at_link(found, "/srv/checks/up")); // Magic links of a procfs in the root, which comes off again whatever the checks found. @@ -182,7 +252,7 @@ fn map_checks(context: &Context<'_>) -> io::Result { ] .into_iter() .map(|(name, link)| { - let found = checks.one(Reserved::default(), "dir", &format!("{link}/new"))?; + let found = checks.one(Reserved::default(), directory, &format!("{link}/new"))?; Ok((name, at_link(found, &link))) }) .collect::>>() @@ -195,21 +265,21 @@ fn map_checks(context: &Context<'_>) -> io::Result { // Kinds. fs::write(base.join("plain"), "")?; fs::create_dir(base.join("directory"))?; - let directory_on_file = checks.one(Reserved::default(), "dir", "/srv/checks/plain")?; + let directory_on_file = checks.one(Reserved::default(), directory, "/srv/checks/plain")?; facts.push("kind.directory", directory_on_file); - let file_on_directory = checks.one(Reserved::default(), "file.txt", "/srv/checks/directory")?; + let file_on_directory = checks.one(Reserved::default(), file, "/srv/checks/directory")?; facts.push("kind.file", file_on_directory); // A mapping may go onto what an earlier one shows, but creates nothing inside it. let mut nested = checks.mapper(Reserved::default())?; let onto = [ - mapping("dir", "/srv/checks/m", Access::ReadWrite)?, - mapping("file.txt", "/srv/checks/m/marker", Access::ReadOnly)?, + mapping(directory, "/srv/checks/m", Access::ReadWrite)?, + mapping(file, "/srv/checks/m/marker", Access::ReadOnly)?, ]; - facts.push("nested.onto", applied(&mut nested, &source, &onto)); - let inside = [mapping("dir/inner", "/srv/checks/m/new", Access::ReadOnly)?]; - facts.push("nested.create", applied(&mut nested, &source, &inside)); - facts.push("nested.source", exists(&sources.join("dir/new"))); + facts.push("nested.onto", applied(&mut nested, export, &onto)); + let inner = format!("{directory}/inner"); + let inside = [mapping(&inner, "/srv/checks/m/new", Access::ReadOnly)?]; + facts.push("nested.create", applied(&mut nested, export, &inside)); facts.push("nested.teardown", torn_down(nested)); // The runtime's reserved targets, the container root's, and a set of the adapter's. @@ -219,7 +289,7 @@ fn map_checks(context: &Context<'_>) -> io::Result { ("time", "/run/nvx/time"), ("time-below", "/run/nvx/time/x"), ] { - let found = checks.one(Reserved::default(), "dir", target)?; + let found = checks.one(Reserved::default(), directory, target)?; facts.push(format!("reserved.{name}"), found); } for (name, target) in [ @@ -230,7 +300,7 @@ fn map_checks(context: &Context<'_>) -> io::Result { ("machine-id", "/etc/machine-id"), ("etc", "/etc"), ] { - let found = checks.one(Reserved::container_root(), "dir", target)?; + let found = checks.one(Reserved::container_root(), directory, target)?; facts.push(format!("reserved.{name}"), found); } for (name, target) in [ @@ -240,7 +310,7 @@ fn map_checks(context: &Context<'_>) -> io::Result { ("adapter-below", "/srv/checks/exact/below"), ] { let adapter = Reserved::new(["/srv/adapter"], ["/srv/checks/exact"]).map_err(invalid)?; - let found = checks.one(adapter, "dir", target)?; + let found = checks.one(adapter, directory, target)?; facts.push(format!("reserved.{name}"), found); } facts.push( @@ -252,10 +322,6 @@ fn map_checks(context: &Context<'_>) -> io::Result { facts.push("read-only.attaches", attaches.to_string()); facts.push("read-only.flags", flags); facts.push("read-only.writable", writable); - facts.push( - "read-only.source", - probes::read(&sources.join("dir/marker")), - ); facts.push( "outside.entries", @@ -270,8 +336,10 @@ fn map_checks(context: &Context<'_>) -> io::Result { struct Checks<'a> { /// The root, the overlay's mount point. root: &'a Path, - /// The root of the probe's sources. + /// The root of the host export. source: &'a SourceRoot, + /// The export's directory, which holds the file `marker`, and in which the guest may write. + directory: &'a str, } impl Checks<'_> { @@ -293,10 +361,11 @@ impl Checks<'_> { Ok(found) } - /// Attaches `dir` read-only at `target` and unmounts it again, [`READ_ONLY_ATTACHES`] times, - /// while another thread tries to open its `marker` for writing, and returns the number of - /// attaches, their flags, `ro,nosuid,nodev` if each had them, and whether the file was ever - /// `writable` or `never`. + /// Attaches the export's directory read-only at `target` and unmounts it again, + /// [`READ_ONLY_ATTACHES`] times, while another thread tries to open its `marker` for writing, + /// and returns the number of attaches, their flags, `ro,nosuid,nodev` if each had them, and + /// whether the file was ever `writable` or `never`. The guest may write the directory through + /// the export, so only the mapping keeps the file read-only. fn read_only(&self, target: &Path) -> io::Result<(usize, String, &'static str)> { let relative = in_root(self.root, target); let marker = target.join("marker"); @@ -321,7 +390,7 @@ impl Checks<'_> { let attached_all = (|| -> io::Result<()> { for _ in 0..READ_ONLY_ATTACHES { let mut mapper = self.mapper(Reserved::default())?; - let mapping = mapping("dir", &relative, Access::ReadOnly)?; + let mapping = mapping(self.directory, &relative, Access::ReadOnly)?; mapper .apply(self.source, &[mapping]) .map_err(io::Error::other)?; @@ -357,18 +426,6 @@ impl Checks<'_> { } } -/// Writes the sources of the probe `name` below [`SOURCES`], as the -/// [module documentation](self) describes, and returns their directory and root. -fn sources(name: &str) -> io::Result<(PathBuf, SourceRoot)> { - let directory = Path::new(SOURCES).join(name); - fs::create_dir_all(directory.join("dir/inner"))?; - fs::create_dir_all(directory.join("out"))?; - fs::write(directory.join("dir/marker"), "source")?; - fs::write(directory.join("file.txt"), "file")?; - let root = SourceRoot::open(&directory)?; - Ok((directory, root)) -} - /// Creates [`OUTSIDE`], if it does not exist, and returns it. fn outside() -> io::Result { fs::create_dir_all(OUTSIDE)?; @@ -385,12 +442,74 @@ fn invalid(error: InvalidMapping) -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, error) } -/// Returns the outcome of a root's mapping: `applied`, or why the root refused it. -fn outcome(mapped: Result<(), root::Error>) -> String { - match mapped { - Ok(()) => "applied".to_owned(), - Err(root::Error::Mapping { error, .. }) => refusal(&error), - Err(error) => format!("failed: {error}"), +/// What a path shows, without following a final link. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + /// Nothing. + Absent, + /// A directory. + Directory, + /// A regular file, of this many bytes. + File(u64), + /// A symbolic link. + Link, + /// Something that the guest may not look up, such as a path that the host export hides. + Denied, + /// Something else, such as a device. + Other, +} + +impl Kind { + /// Returns what `path` shows: [`Absent`](Self::Absent) too if a component of its parent is + /// not a directory. + fn of(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => Ok(Self::Directory), + Ok(metadata) if metadata.is_file() => Ok(Self::File(metadata.len())), + Ok(metadata) if metadata.is_symlink() => Ok(Self::Link), + Ok(_) => Ok(Self::Other), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + Ok(Self::Absent) + } + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => Ok(Self::Denied), + Err(error) => Err(error), + } + } +} + +impl fmt::Display for Kind { + /// Writes `absent`, `directory`, `file SIZE`, `link`, `denied`, or `other`. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Absent => formatter.write_str("absent"), + Self::Directory => formatter.write_str("directory"), + Self::File(size) => write!(formatter, "file {size}"), + Self::Link => formatter.write_str("link"), + Self::Denied => formatter.write_str("denied"), + Self::Other => formatter.write_str("other"), + } + } +} + +/// Returns the names of the entries of the directory `path`, in order, separated by commas, or +/// `unreadable: PROBLEM`. +fn entries(path: &Path) -> String { + let names = fs::read_dir(path).and_then(|entries| { + entries + .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned())) + .collect::>>() + }); + match names { + Ok(mut names) => { + names.sort(); + names.join(",") + } + Err(error) => format!("unreadable: {error}"), } } @@ -526,4 +645,46 @@ mod tests { "/srv/x" ); } + + #[test] + fn the_probes_take_named_targets_and_paths_in_the_export() { + let arguments = |list: &[&str]| { + list.iter() + .map(|&argument| argument.to_owned()) + .collect::>() + }; + let named = arguments(&["dir=srv/maps/dir", "file-2=srv/maps/file.txt"]); + let expected = [ + ("dir", Path::new("srv/maps/dir")), + ("file-2", Path::new("srv/maps/file.txt")), + ]; + assert_eq!(mappings(&named).unwrap(), expected); + assert!(mappings(&[]).unwrap().is_empty()); + for refused in [ + "dir", + "=srv/maps/dir", + "Dir=srv/maps/dir", + "dir.x=srv/maps/dir", + "export=srv/maps/dir", + "hostfs=srv/maps/dir", + "dir=/srv/maps/dir", + "dir=srv/../dir", + "dir=", + ] { + assert!(mappings(&arguments(&[refused])).is_err(), "{refused}"); + } + let twice = mappings(&arguments(&["dir=a", "dir=b"])).unwrap_err(); + assert_eq!(twice, "the map probe names dir twice"); + + assert_eq!(export_path("2"), Ok("2")); + assert_eq!(export_path("0/inner"), Ok("0/inner")); + for refused in ["", "/2", "2/", "2//inner", "./2", "2/..", ".."] { + assert!(export_path(refused).is_err(), "{refused}"); + } + + assert_eq!(Kind::File(4).to_string(), "file 4"); + assert_eq!(Kind::Directory.to_string(), "directory"); + assert_eq!(Kind::Denied.to_string(), "denied"); + assert_eq!(Kind::Absent.to_string(), "absent"); + } } diff --git a/tests/runtime-adapter/src/worker/probes.rs b/tests/runtime-adapter/src/worker/probes.rs index a3f49c058..fba1499ed 100644 --- a/tests/runtime-adapter/src/worker/probes.rs +++ b/tests/runtime-adapter/src/worker/probes.rs @@ -16,8 +16,8 @@ //! //! FILE and DIRECTORY are relative paths in the root, of normal components, and the other probes take //! no arguments. A value that a probe cannot read is `absent` if its file does not exist, and -//! otherwise `unreadable: PROBLEM`. The probes that map beneath the root are those of the -//! [`mapping`](super::mapping) module. +//! otherwise `unreadable: PROBLEM`. The probes of the mappings beneath the root, and of the host +//! export that they come from, are those of the [`mapping`](super::mapping) module. use std::ffi::OsString; use std::fs::{self, File}; @@ -103,7 +103,7 @@ impl Context<'_> { } /// Returns the overlay's `upper` directory. - fn upper_directory(&self) -> PathBuf { + pub(super) fn upper_directory(&self) -> PathBuf { self.upper.join("upper") } } @@ -153,7 +153,7 @@ pub fn run(probe: &Probe, context: &Context<'_>, held: &mut Vec) -> Result /// Returns the path that the argument `argument` of the probe `name` names in the root: a relative /// path of normal components, or the problem. -fn path_argument<'a>(name: &str, argument: &'a str) -> Result<&'a Path, String> { +pub(super) fn path_argument<'a>(name: &str, argument: &'a str) -> Result<&'a Path, String> { let path = Path::new(argument); let mut components = path.components().peekable(); let normal = components.peek().is_some() @@ -505,7 +505,7 @@ pub(super) struct Mount { /// The mount's options. pub(super) options: String, /// The file system's type. - kind: String, + pub(super) kind: String, /// The mount's source. source: String, /// The file system's own options. diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index 56d5b37f9..fad70eda4 100644 --- a/tests/runtime-adapter/tests/vm_layered.rs +++ b/tests/runtime-adapter/tests/vm_layered.rs @@ -20,6 +20,12 @@ //! panic on an error, as [`corrupt`] describes. Others give it a cyclic journal, with and without //! the file system's `needs_recovery` bit, as [`add_cyclic_journal`] describes. //! +//! A scenario of a mapping [`Table`] shares host directories and a file, which the test writes in +//! its directory, with the root, through the launch's `--share` options: the guest maps them +//! beneath the root from the host export before the sandbox starts, and the scenario then checks +//! the mappings in the guest and the files on the host. Others share what the guest, or the host +//! program before it spawns the VM, refuses. +//! //! ```text //! cargo test --locked --features host --test vm_layered -- --ignored //! ``` @@ -27,7 +33,7 @@ mod common; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{OsStr, OsString}; use std::fs::{self, File}; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -49,6 +55,9 @@ const FAILED: i32 = 1; /// The exit status of the host program's command whose guest reported that a stage of the root /// failed. const ROOT_FAILED: i32 = 4; +/// The exit status of the host program's launch whose guest failed a mapping of the table, and +/// stopped gracefully. +const TABLE_FAILED: i32 = 6; /// The size of the guest's pages, in bytes. const PAGE: u64 = 4096; /// How many of the bytes available in an upper a file that fills it may leave, which the file's @@ -90,6 +99,37 @@ const LAYER_BLOCKS: usize = 128; /// The number of inodes of that layer, of which the first ten are reserved, as in every ext2 file /// system, and the second is its top directory. const LAYER_INODES: usize = 16; +/// The directory of a scenario's sandbox that holds the host directories and the file that a +/// mapping table shares, as [`write_sources`] writes them. +const SOURCES: &str = "sources"; +/// The files of the [`SOURCES`], each a path in them, with components separated by `/`, and its +/// contents. +const SOURCE_FILES: [(&str, &str); 4] = [ + ("dir/marker", "marker"), + ("dir/secret", "secret"), + ("file.txt", "file"), + ("out/marker", "marker"), +]; +/// The empty directory of a scenario's sandbox beside the [`SOURCES`], which the adapter does not +/// authorize: the sources are the one host directory that it authorizes. +const OUTSIDE: &str = "outside"; +/// The shares of a mapping table: each host path, a path in the [`SOURCES`], and its target in the +/// root, with `:ro` for a read-only share. The launch hides `dir/secret`. +const SHARES: [(&str, &str); 4] = [ + ("dir", "/srv/maps/dir:ro"), + ("dir/inner", "/srv/maps/dir/inner"), + ("file.txt", "/srv/maps/file.txt:ro"), + ("out", "/srv/maps/out"), +]; +/// The guest tree that the adapter reserves in the scenarios of its reserved targets. +const ADAPTER_TREE: &str = "/srv/adapter"; +/// The `map` probe of the [`SHARES`]: each target, and the hidden file in `dir`. +const MAP_TABLE: &str = "map dir=srv/maps/dir inner=srv/maps/dir/inner file=srv/maps/file.txt \ + out=srv/maps/out secret=srv/maps/dir/secret"; +/// The `map-checks` probe of the [`SHARES`], from the export's child `2`, `out`, and its child `1`, +/// `file.txt`: the plan exports the outermost shares as its children, in the order of their host +/// paths. +const MAP_CHECKS: &str = "map-checks 2 1"; /// The image of a scenario's root. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -151,10 +191,12 @@ struct Root { journal: Option, /// The bound of the first partition, as the launch names it, unless the guest's default. first_partition_bound: Option, + /// The host paths that the launch shares with the root. + table: Table, } /// The root of the scenarios unless they say otherwise: NVX's EROFS image, with its own identity, -/// over a copy of the scratch template, with the guest's defaults. +/// over a copy of the scratch template, with the guest's defaults, and no share. const ROOT: Root = Root { image: Image::Erofs, top_layer: TopLayer::Sound, @@ -164,6 +206,7 @@ const ROOT: Root = Root { overlay: None, journal: None, first_partition_bound: None, + table: Table::None, }; /// The root of the scenarios of a GPT disk unless they say otherwise: the disk, with its own GUID, @@ -261,6 +304,92 @@ fn other_identity(identity: &str) -> String { format!("{start}{}", if last == "0" { "1" } else { "0" }) } +/// The host paths that a scenario's launch shares with the root, from the [`SOURCES`] that +/// [`write_sources`] writes: none, the [`SHARES`], or those and one more share, which the guest, +/// or the host program before it spawns the VM, refuses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Table { + /// No share: the launch exports nothing, and the mapping table is empty. + None, + /// The [`SHARES`]: a read-only directory, with a read-write directory inside it, a read-only + /// file, and a read-write directory, in a table of four mappings, which the plan orders by the + /// depth of their targets, then by their targets. + Maps, + /// The [`SHARES`], then `out` again, onto `/usr/lib/os-release`, a file of NVX's Ubuntu image: + /// the fourth mapping of the table, a directory onto a file, which the guest refuses. + Failing, + /// The [`SHARES`], then a read-only share inside `out`, which the guest may write. + ReadOnlyInsideWritable, + /// The [`SHARES`], then a share at a target inside the [`ADAPTER_TREE`], which the adapter + /// reserves. + InReservedTree, + /// The [`SHARES`], then a share at a target that would cover the [`ADAPTER_TREE`]. + OverReservedTree, + /// The [`SHARES`], then the [`OUTSIDE`] directory, which the adapter does not authorize. + Unauthorized, +} + +impl Table { + /// Returns the launch's options of the table, with the sources in `sandbox`, the sandbox's + /// directory: each share, with the [`ADAPTER_TREE`] reserved for the scenarios of reserved + /// targets, `dir/secret` hidden, and the [`SOURCES`] as the one host directory that the + /// adapter authorizes. + fn options(self, sandbox: &Path) -> Vec { + let sources = sandbox.join(SOURCES); + let extra = match self { + Self::None => return Vec::new(), + Self::Maps => None, + Self::Failing => Some((source(&sources, "out"), "/usr/lib/os-release")), + Self::ReadOnlyInsideWritable => { + Some((source(&sources, "out/inner"), "/srv/maps/out/inner:ro")) + } + Self::InReservedTree => Some((source(&sources, "dir/inner"), "/srv/adapter/inner")), + Self::OverReservedTree => Some((source(&sources, "out"), "/srv")), + Self::Unauthorized => Some((sandbox.join(OUTSIDE), "/srv/outside")), + }; + let shares = SHARES + .iter() + .map(|(path, target)| (source(&sources, path), *target)) + .chain(extra); + let mut options = Vec::new(); + for (host, target) in shares { + let mut share = host.into_os_string(); + share.push(":"); + share.push(target); + options.extend(["--share".into(), share]); + } + if matches!(self, Self::InReservedTree | Self::OverReservedTree) { + options.extend(["--reserved".into(), ADAPTER_TREE.into()]); + } + options.extend([ + "--deny".into(), + source(&sources, "dir/secret").into_os_string(), + "--share-root".into(), + sources.into_os_string(), + ]); + options + } + + /// Returns how many mappings the root configuration of a launch with the table announces. + fn mapping_count(self) -> u32 { + match self { + Self::Maps => 4, + Self::Failing => 5, + Self::None + | Self::ReadOnlyInsideWritable + | Self::InReservedTree + | Self::OverReservedTree + | Self::Unauthorized => 0, + } + } +} + +/// Returns the host path of `path`, a path in the `sources`, with components separated by `/`. +fn source(sources: &Path, path: &str) -> PathBuf { + path.split('/') + .fold(sources.to_path_buf(), |host, name| host.join(name)) +} + /// What a scenario expects of its VM. enum Expect { /// The guest assembles the root, the check probes the VM, and the stop tears the root down, @@ -272,6 +401,13 @@ enum Expect { /// The guest reports that the stage failed with this problem, and the VM ends with its /// status, before the guest mounted a scratch. Fails(RootStage, &'static str), + /// The guest assembles the root, applies this many mappings of the table, and fails the next + /// with a problem that holds this text: the launch sends no more of the table, nor `Start`, and + /// stops the VM, whose guest tears the root down, and which leaves the host's sources as they + /// were. + TableFails(u32, &'static str), + /// The host program refuses the launch's shares with this problem, before it spawns the VM. + Unlaunched(&'static str), /// The scenario makes this change between its staging and its launch, which the host program /// then refuses with this problem, before it spawns the VM. Once the scenario stages the /// images again, the guest assembles the root, which [`check_root`] probes, and the stop tears @@ -516,22 +652,73 @@ scenarios! { root: ROOT, expect: Expect::TeardownFails(hold_root, "failed to unmount the overlay: "), }; - /// Host paths mapped beneath the assembled root, as an adapter maps them. Through the root: - /// zero mappings, then a table in order, each with its access, `nosuid`, and `nodev`, on - /// targets created on the overlay. Through mappers of the adapter's own: the refusal of a - /// link and of a magic link at each component, of the wrong kinds, of a target that would be - /// created inside another mapping, and of reserved targets, the runtime's, the container - /// root's, and the adapter's own; and a read-only mapping that is never writable. The stop - /// unmounts the root's mappings before the overlay. - mappings_apply_beneath_the_root: Scenario { - root: ROOT, - expect: Expect::Ready(check_mappings), + /// The host's mapping table, which the guest maps beneath the root from the host export before + /// the sandbox starts: the export, mounted out of the root; the mappings, mounted in the plan's + /// order, each with its access, `nosuid`, and `nodev`, on targets created on the overlay; a + /// file that the launch hides; and writes that reach the host's writable sources alone. Then, + /// from the export, through mappers of the adapter's own: the refusal of a link and of a magic + /// link at each component, of the wrong kinds, of a target that would be created inside + /// another mapping, and of reserved targets, the runtime's, the container root's, and the + /// adapter's own; and a read-only mapping that is never writable. The stop unmounts the + /// mappings and the export before the overlay. + erofs_over_scratch_maps_the_table: Scenario { + root: Root { table: Table::Maps, ..ROOT }, + expect: Expect::Ready(check_table_and_mappers), }; - /// A mapping through a symbolic link in the root, which the root refuses, and after which it - /// refuses every mapping. - a_refused_mapping_latches_the_root: Scenario { - root: ROOT, - expect: Expect::Ready(check_refused_mapping), + /// The table over a tmpfs in RAM. + erofs_over_ram_maps_the_table: Scenario { + root: Root { upper: Upper::Ram, table: Table::Maps, ..ROOT }, + expect: Expect::Ready(check_table), + }; + /// The table on a GPT disk, whose layers hold no `/srv`, over the scratch. + gpt_over_scratch_maps_the_table: Scenario { + root: Root { table: Table::Maps, ..GPT }, + expect: Expect::Ready(check_table), + }; + /// The table on the disk over a tmpfs in RAM. + gpt_over_ram_maps_the_table: Scenario { + root: Root { upper: Upper::Ram, table: Table::Maps, ..GPT }, + expect: Expect::Ready(check_table), + }; + /// A mapping of the table that the guest refuses, which blocks the start: the launch stops the + /// VM, which tears the root down. + a_failed_mapping_tears_the_vm_down: Scenario { + root: Root { table: Table::Failing, ..ROOT }, + expect: Expect::TableFails( + 3, + "failed to mount the mapping at /usr/lib/os-release: the target exists as another kind \ + than its source", + ), + }; + /// A read-only share inside a read-write one, which the host could not keep read-only, and + /// which the launch refuses before it spawns the VM. + a_read_only_share_inside_a_writable_one_is_refused_before_launch: Scenario { + root: Root { table: Table::ReadOnlyInsideWritable, ..ROOT }, + expect: Expect::Unlaunched("where the host cannot keep it read-only"), + }; + /// A share inside a tree that the adapter reserves, which the launch refuses before it spawns + /// the VM. + a_share_inside_a_reserved_tree_is_refused_before_launch: Scenario { + root: Root { table: Table::InReservedTree, ..ROOT }, + expect: Expect::Unlaunched( + "has the guest target /srv/adapter/inner, which would cover the guest's /srv/adapter", + ), + }; + /// A share that would cover a tree that the adapter reserves, which the launch refuses before + /// it spawns the VM. + a_share_over_a_reserved_tree_is_refused_before_launch: Scenario { + root: Root { table: Table::OverReservedTree, ..ROOT }, + expect: Expect::Unlaunched( + "has the guest target /srv, which would cover the guest's /srv/adapter", + ), + }; + /// A share outside the host directories that the adapter authorizes, which the launch refuses + /// before it spawns the VM. + an_unauthorized_host_path_is_refused_before_launch: Scenario { + root: Root { table: Table::Unauthorized, ..ROOT }, + expect: Expect::Unlaunched( + "lies outside the host directories that --share-root authorizes", + ), }; /// Executions that enter the root as root profiles do, through `nvx::guest::entry`: user ID /// 0 with each allowed capability alone, with none, and with every one in each publication @@ -586,7 +773,11 @@ fn run(name: &str, scenario: Scenario) { let distro = root.distro(&fixtures, &sandbox.root); let images = root.images(&distro, &scratch); let images = images.iter().map(OsString::as_os_str).collect::>(); - let options = root.options(&distro, &scratch); + let mut options = root.options(&distro, &scratch); + if root.table != Table::None { + write_sources(&sandbox.root); + options.extend(root.table.options(&sandbox.root)); + } let options = options.iter().map(OsString::as_os_str).collect::>(); if let Expect::Refused(change, problem) = scenario.expect { if change != Change::Unstaged { @@ -620,6 +811,38 @@ fn run(name: &str, scenario: Scenario) { ); return; } + Expect::TableFails(mapped, problem) => { + let launched = sandbox.launch(&options, TABLE_FAILED); + assert_eq!(launched["root"], "ready", "{launched:?}"); + assert_eq!(launched["build"], GUEST_BUILD); + // The guest applied the mappings before the one that it refused, and the launch sent + // neither the rest of the table nor `Start`. + assert_eq!(launched["table"], "failed", "{launched:?}"); + assert_eq!(launched["mapped"], mapped.to_string(), "{launched:?}"); + assert!(launched["problem"].contains(problem), "{launched:?}"); + assert!(!launched.contains_key("sandbox"), "{launched:?}"); + // The launch stopped the VM, whose guest tore the root down, mappings included. + assert_eq!(launched["stopped"], "graceful", "{launched:?}"); + assert_eq!(launched["outcome"], "success 0", "{launched:?}"); + assert_eq!(launched["teardown"], "complete", "{launched:?}"); + check_record(&sandbox, &root, &distro, &scratch); + assert_eq!(sandbox.record(), None); + assert!( + !(over_scratch && needs_recovery(&scratch)), + "the stop left the scratch mounted" + ); + check_sources(&sandbox.root, false); + return; + } + Expect::Unlaunched(problem) => { + let refused = sandbox.launch(&options, FAILED); + assert!(refused.is_empty(), "{refused:?}"); + let stderr = sandbox.stderr(); + assert!(stderr.contains(problem), "{stderr}"); + sandbox.assert_unlaunched(); + check_sources(&sandbox.root, false); + return; + } Expect::Ready(check) => (check, None), Expect::TeardownFails(check, problem) => (check, Some(problem)), Expect::Refused(..) => (check_root as fn(&mut Vm<'_>), None), @@ -627,10 +850,10 @@ fn run(name: &str, scenario: Scenario) { let launched = sandbox.launch(&options, 0); assert_eq!(launched["root"], "ready"); // The guest agent of the build that the host program ships assembled the root, applied the - // host's empty mapping table, and started the sandbox. + // host's mapping table, and started the sandbox. assert_eq!(launched["build"], GUEST_BUILD); assert_eq!(launched["table"], "applied"); - assert_eq!(launched["mapped"], "0"); + assert_eq!(launched["mapped"], root.table.mapping_count().to_string()); assert_eq!(launched["sandbox"], "started"); let config = check_record(&sandbox, &root, &distro, &scratch); let vmm = sandbox @@ -749,6 +972,7 @@ fn check_record(sandbox: &Sandbox, root: &Root, distro: &Distro, scratch: &Path) first_partition_bound: root.first_partition_bound, overlay_mode: root.overlay, scratch_journal: root.journal, + mapping_count: root.table.mapping_count(), ..RootConfig::default() }; let sent = String::from_utf8(config.encode()).unwrap(); @@ -1017,6 +1241,15 @@ fn check_erofs_over_scratch(vm: &mut Vm<'_>) { facts["processes.worker.command"].ends_with(&format!(" {sibling}")), "{facts:?}" ); + + // Zero mappings do no work: the launch exported nothing, so the guest mounted no export, and + // nothing is mounted below the root. + let facts = vm.probe(&["map"]); + assert_eq!(facts["map.export.count"], "0", "{facts:?}"); + assert!(!facts.contains_key("map.export.type"), "{facts:?}"); + assert_eq!(facts["map.hostfs.mode"], "absent", "{facts:?}"); + assert_eq!(facts["map.mounts"], "", "{facts:?}"); + assert_eq!(facts["map.latched"], "no", "{facts:?}"); } /// Keeps the root of the `busy_root_fails_the_teardown` scenario busy. @@ -1025,47 +1258,82 @@ fn hold_root(vm: &mut Vm<'_>) { assert_eq!(facts["hold.held"], format!("{}/rootfs", vm.runtime)); } -/// Checks the VM of the `mappings_apply_beneath_the_root` scenario. -fn check_mappings(vm: &mut Vm<'_>) { - let facts = vm.probe(&["map", "map-checks"]); +/// Checks the mappings of the table of [`SHARES`], which the guest mapped beneath the root from +/// the host export before the sandbox started, and the host's sources, which they show. +fn check_table(vm: &mut Vm<'_>) { + let facts = vm.probe(&[MAP_TABLE]); let fact = |key: &str| facts[&format!("map.{key}")].as_str(); - // Zero mappings do no work; a table applies in order. - assert_eq!((fact("zero"), fact("zero.mounts")), ("applied", "0")); - assert_eq!(fact("table"), "applied"); + // The export, mounted once, read-write since some of its children are, without set-user-ID + // programs or devices, below a directory of the runtime's that only root may enter. + assert_eq!(fact("export.count"), "1"); + assert_eq!(fact("export.type"), "virtiofs"); + let export = options(fact("export.options")); + assert!( + export.is_superset(&BTreeSet::from(["rw", "nosuid", "nodev"])), + "{export:?}" + ); + assert_eq!((fact("hostfs.mode"), fact("hostfs.owner")), ("0700", "0:0")); + + // The table's mappings, and nothing else, in the plan's order: by the depth of their targets, + // then by their targets. assert_eq!( fact("mounts"), - "/srv/maps/dir,/srv/maps/dir/inner,/srv/maps/file.txt,/srv/maps/out" + "/srv/maps/dir,/srv/maps/file.txt,/srv/maps/out,/srv/maps/dir/inner" ); - // Each mapping has its access, `nosuid`, and `nodev`, and writes reach the sources of the - // writable ones only, the one inside a read-only mapping included. - for (name, access) in [ - ("dir", "ro"), - ("inner", "rw"), - ("file", "ro"), - ("out", "rw"), + // Each mapping has its access, `nosuid`, and `nodev`, and the guest may write the writable + // ones alone, the one inside a read-only mapping included. + for (name, access, write) in [ + ("dir", "ro", "refused"), + ("inner", "rw", "allowed"), + ("file", "ro", "refused"), + ("out", "rw", "allowed"), ] { let found = options(fact(&format!("{name}.options"))); for flag in [access, "nosuid", "nodev"] { assert!(found.contains(flag), "{name}: {found:?}"); } + assert_eq!(fact(&format!("{name}.write")), write, "{name}"); } - assert_eq!((fact("dir.read"), fact("dir.write")), ("source", "refused")); - assert_eq!((fact("file.read"), fact("file.write")), ("file", "refused")); - for name in ["inner", "out"] { - let written = ( - fact(&format!("{name}.write")), - fact(&format!("{name}.source")), - ); - assert_eq!(written, ("allowed", "runtime-adapter"), "{name}"); - } - // The missing targets were created on the overlay, in its upper. + // What each shows, as the host holds it, but for the file that the launch hides: its directory + // does not list it, and the host refuses its lookup. + assert_eq!( + (fact("dir.kind"), fact("dir.entries")), + ("directory", "inner,marker") + ); + assert_eq!( + (fact("inner.kind"), fact("inner.entries")), + ("directory", "") + ); + assert_eq!((fact("file.kind"), fact("file.read")), ("file 4", "file")); assert_eq!( - (fact("upper.directory"), fact("upper.file")), - ("yes", "yes") + (fact("out.kind"), fact("out.entries")), + ("directory", "inner,marker") ); + assert_eq!( + (fact("secret.options"), fact("secret.kind")), + ("none", "denied") + ); + // The guest created the missing targets on the overlay, in its upper, empty: those of the + // outermost mappings, but none inside another mapping. + for (name, upper) in [ + ("dir", "directory"), + ("inner", "absent"), + ("file", "file 0"), + ("out", "directory"), + ("secret", "absent"), + ] { + assert_eq!(fact(&format!("{name}.upper")), upper, "{name}"); + } assert_eq!(fact("latched"), "no"); + check_sources(&vm.sandbox.root, true); +} +/// Checks the VM of the `erofs_over_scratch_maps_the_table` scenario: its [table](check_table), +/// then mappers of the adapter's own, from the export. +fn check_table_and_mappers(vm: &mut Vm<'_>) { + check_table(vm); + let facts = vm.probe(&[MAP_CHECKS]); let check = |key: &str| facts[&format!("map-checks.{key}")].as_str(); // No link or magic link at any component leads a mapping anywhere. for key in [ @@ -1086,7 +1354,6 @@ fn check_mappings(vm: &mut Vm<'_>) { // A mapping may go onto what an earlier one shows, but creates nothing inside it. assert_eq!(check("nested.onto"), "applied"); assert_eq!(check("nested.create"), "foreign /srv/checks/m/new"); - assert_eq!(check("nested.source"), "absent"); assert_eq!(check("nested.teardown"), "complete"); // The runtime's, the container root's, and the adapter's own reserved targets, and the // ancestors of reserved trees, but not what is below a reserved path. @@ -1109,27 +1376,73 @@ fn check_mappings(vm: &mut Vm<'_>) { } assert_eq!(check("reserved.adapter-below"), "applied"); assert_eq!(check("reserved.created"), "absent"); - // A read-only mapping is restricted before it is attached, so never writable. + // A read-only mapping of a directory that the guest may write through the export is + // restricted before it is attached, so never writable. assert_eq!(check("read-only.attaches"), "200"); assert_eq!(check("read-only.flags"), "ro,nosuid,nodev"); assert_eq!(check("read-only.writable"), "never"); - assert_eq!(check("read-only.source"), "source"); - // Nothing went outside the root, and the checks unmounted what they mapped. + // Nothing went outside the root, the checks unmounted what they mapped, and they wrote + // nothing to the host's sources. assert_eq!(check("outside.entries"), "0"); assert_eq!(check("outside.mounts"), "0"); assert_eq!(check("left"), "0"); + check_sources(&vm.sandbox.root, true); } -/// Checks the VM of the `a_refused_mapping_latches_the_root` scenario. -fn check_refused_mapping(vm: &mut Vm<'_>) { - let facts = vm.probe(&["map-refused"]); - let fact = |key: &str| facts[&format!("map-refused.{key}")].as_str(); - assert_eq!(fact("through-link"), "link /srv/link"); - assert_eq!(fact("latched"), "yes"); - assert_eq!(fact("later"), "latched"); - assert_eq!(fact("later.target"), "absent"); - assert_eq!(fact("mounts"), "0"); - assert_eq!(fact("outside"), "0"); +/// Writes the [`SOURCES`] that a scenario's mapping table shares, in `sandbox`, the sandbox's +/// directory: `dir`, with the [`SOURCE_FILES`] `marker` and `secret`, which the launch hides, and +/// the empty directory `inner`; `file.txt`; and `out`, with the file `marker` and the empty +/// directory `inner`. Beside them, the empty directory [`OUTSIDE`]. +fn write_sources(sandbox: &Path) { + let sources = sandbox.join(SOURCES); + for directory in ["dir/inner", "out/inner"] { + fs::create_dir_all(source(&sources, directory)).unwrap(); + } + for (file, contents) in SOURCE_FILES { + fs::write(source(&sources, file), contents).unwrap(); + } + fs::create_dir(sandbox.join(OUTSIDE)).unwrap(); +} + +/// Checks the files of the [`SOURCES`] in `sandbox`, the sandbox's directory: those that +/// [`write_sources`] wrote, unchanged, and, once `written`, the files that the `map` probe wrote +/// through the writable mappings of `dir/inner` and `out`, and nothing else; and the empty +/// [`OUTSIDE`]. +fn check_sources(sandbox: &Path, written: bool) { + let mut expected: BTreeMap> = ["dir", "dir/inner", "out", "out/inner"] + .into_iter() + .map(|directory| (directory.to_owned(), None)) + .collect(); + for (file, contents) in SOURCE_FILES { + expected.insert(file.to_owned(), Some(contents.to_owned())); + } + if written { + for file in ["dir/inner/written", "out/written"] { + expected.insert(file.to_owned(), Some("runtime-adapter".to_owned())); + } + } + assert_eq!(tree(&sandbox.join(SOURCES)), expected); + assert_eq!(tree(&sandbox.join(OUTSIDE)), BTreeMap::new()); +} + +/// Returns what the directory `directory` holds, at any depth: each path in it, with components +/// separated by `/`, and a file's contents, or `None` for a directory. +fn tree(directory: &Path) -> BTreeMap> { + let mut found = BTreeMap::new(); + let mut pending = vec![(directory.to_path_buf(), String::new())]; + while let Some((path, prefix)) = pending.pop() { + for entry in fs::read_dir(&path).unwrap() { + let entry = entry.unwrap(); + let name = format!("{prefix}{}", entry.file_name().to_str().unwrap()); + if entry.file_type().unwrap().is_dir() { + pending.push((entry.path(), format!("{name}/"))); + found.insert(name, None); + } else { + found.insert(name, Some(fs::read_to_string(entry.path()).unwrap())); + } + } + } + found } /// Writes the GPT disk of the scenarios at `path`, as UEFI lays out a disk, with a protective MBR,