From e004a1bc6066add12a626b800c4d78330ff4a3d3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 01:21:05 -0700 Subject: [PATCH 01/14] Cut CI and repository config comments to one line each The workflow's stacked comments become one line, the launcher-gap step loses its shouted name, and two multi-line echo messages are tightened. Step logic is unchanged. --- .gitattributes | 3 +- .github/actions/prepare/action.yml | 18 ++---- .github/workflows/ci.yml | 100 +++++++++-------------------- .shellcheckrc | 4 +- 4 files changed, 36 insertions(+), 89 deletions(-) diff --git a/.gitattributes b/.gitattributes index 748cd70d..722c576e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,4 @@ -# LF everywhere, whatever core.autocrlf says: a CRLF script fails with -# "bad interpreter". +# LF everywhere, whatever core.autocrlf says: a CRLF script fails with "bad interpreter". * text=auto eol=lf *.sh text eol=lf diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 42aabf1c..d2f9cb8d 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -12,11 +12,7 @@ runs: steps: - shell: bash run: | - # Everything the build or a test writes, temporary files and Rust - # targets included, goes under /mnt, and the image's SDKs are deleted: - # a full disk kills the runner before it writes a report. Deleting them - # takes minutes, so it runs on in the background, detached from the - # step's output; nothing needs the room that soon. + # A full disk kills the runner unreported: write under /mnt, delete the SDKs in the background. sudo mkdir -p /mnt/kryptik sudo chown "$(id -u):$(id -g)" /mnt/kryptik mkdir -p "$KRYPTIK_WORK" "$KRYPTIK_SOURCES" "$KRYPTIK_OUT" /mnt/kryptik/tmp /mnt/kryptik/cargo @@ -33,8 +29,7 @@ runs: run: | sudo pip install --break-system-packages virt-firmware==26.9 virt-fw-vars --help > /dev/null - # /dev/kvm exists on a hosted runner but only root may open it; without - # it every VM falls back to TCG and runs into its timeout. + # Only root may open /dev/kvm on a hosted runner; without it every VM falls back to TCG and times out. echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm @@ -46,8 +41,7 @@ runs: with: path: /mnt/kryptik/sources key: sources-v3-${{ hashFiles('sources.lock') }} - # A changed lock restores the previous set and fetches only what is - # new; every file is still hashed against the lock. + # A changed lock restores the previous set; every file is still hashed against the lock. restore-keys: ${{ inputs.sources == 'fetch' && 'sources-' || '' }} - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' @@ -55,12 +49,10 @@ runs: run: | make sources ./tools/prune-sources.sh - # The signatures too, so the set this job saves is the one CI's - # signature gate reads from disk; this pass is not the verdict. + # Fetches the signatures into the saved set for CI's gate; this pass is not the verdict. ./tools/verify-signatures.sh > /dev/null 2>&1 || true - # Saved as soon as the set is verified, not when the job ends: a job that - # fails later would otherwise fetch the lock's new files again next time. + # Saved now rather than at the end, so a job that fails later does not refetch the new files. - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b678181e..ec73f311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,7 @@ on: branches: [main] pull_request: schedule: - # Weekly, for the kernel EOL check: a pinned kernel does not change, but - # its support status does (ADR-009). + # Weekly: a pinned kernel's support status changes even when the pin does not (ADR-009). - cron: '0 6 * * 1' permissions: @@ -23,8 +22,7 @@ jobs: - name: Every commit is DevomB run: | - # On a pull request the checkout is GitHub's synthetic merge commit, - # which never reaches main; check the branch head instead. + # A pull request's checkout is a synthetic merge commit; check the branch head instead. if [ "${{ github.event_name }}" = "pull_request" ]; then ./tools/check-commit-identity.sh "${{ github.event.pull_request.head.sha }}" else @@ -55,8 +53,7 @@ jobs: - name: Tool fixture suites run: | - # Exit 77 means "cannot run here" (a suite that needs root and a - # built sysroot). Those are listed separately, never counted as passes. + # Exit 77 means the suite cannot run here: it is listed as skipped, never counted as a pass. fail=0 skipped="" for t in tools/tests/*.sh tools/tests/*.py; do @@ -87,8 +84,7 @@ jobs: - name: Executable bits are recorded run: | - # A commit from Windows records 100644, and a Linux clone then fails - # with "Permission denied". + # A commit from Windows records 100644; a Linux clone then fails with "Permission denied". fail=0 for f in build/stages/*.sh tools/*.sh tools/tests/*.sh tools/tests/*.py \ tools/image/*.sh tools/image/*.py \ @@ -112,9 +108,7 @@ jobs: manifest: name: Source manifest runs-on: ubuntu-latest - # The Distro workflow's path: a cache's version is a hash of its path, so - # under any other one a pull request misses main's copy and saves a whole - # second one of its own, and those evicted the build trees. + # The Distro workflow's path: a cache's version hashes its path, so any other misses main's copy. env: KRYPTIK_SOURCES: /mnt/kryptik/sources steps: @@ -125,9 +119,7 @@ jobs: sudo mkdir -p /mnt/kryptik && sudo chown "$(id -u):$(id -g)" /mnt/kryptik ./tools/fetch-sources.sh --list - # The provenance gate hashes the tarballs, so they are cached. restore-keys - # brings back the last set; `make sources` re-hashes every file against - # the lock and fetches only what is missing, and the prune drops the rest. + # Restores the last set; `make sources` checks it against the lock and fetches what is missing. - name: Upstream sources (cached by sources.lock) id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 @@ -137,9 +129,7 @@ jobs: restore-keys: | sources- - # The signatures are fetched here too, into the set the cache keeps: - # the gate below then reads them from disk instead of asking sixty - # hosts again on every run. What this pass says is not the verdict. + # Fetches the signatures into the cached set for the gate below; this pass is not the verdict. - name: Fetch and verify the sources if: steps.sources.outputs.cache-hit != 'true' run: | @@ -147,8 +137,7 @@ jobs: ./tools/prune-sources.sh ./tools/verify-signatures.sh > /dev/null 2>&1 || true - # Not from a pull request: its cache is scoped to its merge ref, so every - # one whose lock differs from main's would keep a whole copy of its own. + # Not from a pull request: its cache is scoped to the merge ref and would duplicate the set. - name: Save the sources if: ${{ github.event_name != 'pull_request' && steps.sources.outputs.cache-hit != 'true' }} uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 @@ -156,8 +145,7 @@ jobs: path: /mnt/kryptik/sources key: ${{ steps.sources.outputs.cache-primary-key }} - # Informational: a mirror that is down or throttling is not a defect here. - # A one-byte ranged GET, because several GNU mirrors refuse HEAD. + # Informational, as mirrors go down; a one-byte ranged GET, since some GNU mirrors refuse HEAD. - name: Every source URL is reachable continue-on-error: true run: | @@ -183,17 +171,13 @@ jobs: done < <(./tools/fetch-sources.sh --list) echo if [ "$missing" -gt 0 ]; then - echo "$missing source(s) unreachable this run." - echo "Usually a transient mirror problem. Investigate only if the" - echo "same package fails across several runs." + echo "$missing source(s) unreachable; investigate if the same one fails across several runs." else echo "all sources reachable" fi - name: Provenance of unsigned sources - # `gh` needs GH_TOKEN even for a public repository. Strict on push and - # on the schedule; informational on pull requests, where a fork gets - # no token. + # Strict on push and schedule; informational on pull requests, where a fork gets no token. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -208,12 +192,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # One assurance class per source and no total: a signature, a signed - # tag and a publisher checksum are different strengths of evidence. + # One class per source and no total: the classes are different strengths of evidence. ./tools/provenance-inventory.sh - # The gate: every signature verifies against a key a publisher states, - # or tools/source-notes.tsv says which routes to the key were tried. + # Each signature must verify against its publisher's key, or tools/source-notes.tsv says why not. - name: Signatures, strict run: ./tools/verify-signatures.sh --strict @@ -239,9 +221,7 @@ jobs: - name: kryptikd unit tests run: cd compartments/kryptikd && cargo test - # kryptik-wlproxy and zoneid: their unit and live tests, and the shipped - # zone files against the identity invariant. The acceptance runs the same - # suite hours later; this is the answer a compositor change gets first. + # wlproxy and zoneid tests, and the shipped zone files against the identity invariant. - name: Compositor tests run: tools/tests/compositor.sh @@ -252,17 +232,14 @@ jobs: - name: Allow unprivileged user namespaces run: | - # Ubuntu 24.04 restricts unprivileged user namespaces, which the zone - # suites need here. (Kryptik itself disables them: zones are created - # with privilege.) + # Ubuntu 24.04 restricts unprivileged user namespaces, which the zone suites need here. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 echo "userns check:" unshare --user --map-root-user id -u - name: kryptikd builds for musl run: | - # The shipped binary is static musl; glibc-only assumptions show up - # here (ioctl's request type differs, for one). + # The shipped binary is static musl; this catches glibc-only assumptions. rustup target add x86_64-unknown-linux-musl cd compartments/kryptikd cargo build --release --target x86_64-unknown-linux-musl @@ -275,24 +252,17 @@ jobs: - name: Real-launcher suite (the launch path itself) run: | - # adversarial.sh tests the primitives; this tests `kryptikd run` - # applying them in order. Unprivileged here, so groups K (privileged - # launch) and M (cgroup limits) report NOT RUN. + # Unprivileged here, so groups K (privileged launch) and M (cgroup limits) report NOT RUN. cd compartments/kryptikd && cargo build --quiet cd ../.. && ./compartments/tests/launcher.sh | tee /tmp/launcher.out exit "${PIPESTATUS[0]}" - - name: The runner must ADMIT what it could not test + - name: The launcher suite reports its gaps run: | - # On a runner the launcher suite must end "PASSED WITH GAPS". A clean - # pass would mean the root-only checks ran vacuously or stopped - # reporting themselves. They run as root in the Distro workflow's - # acceptance (zones-test). + # Groups K and M need root: they run in the Distro workflow's zones-test, never here. if ! grep -q 'PASSED WITH GAPS' /tmp/launcher.out; then - echo "::error::The launcher suite reported a clean pass on a CI runner." - echo "Group K (privileged launch) and group M (cgroup limits) cannot run" - echo "here. If they are no longer reported as NOT RUN, they are either" - echo "running vacuously or have been dropped. Neither is a pass." + echo "::error::The launcher suite passed cleanly on a runner, where groups K and M cannot run." + echo "They either ran vacuously or were dropped; neither is a pass." grep -E 'passed,|not run' /tmp/launcher.out || true exit 1 fi @@ -312,22 +282,17 @@ jobs: | **target kernel (root, installed system, userns restricted)** | **no — the Distro workflow's acceptance run** | The privileged launch path, the cgroup limits, the zone lifecycle - under root and the boot itself are tested by `make acceptance` on - the built media under KVM. A green tick here is not evidence about - them. + under root and the boot are tested by `make acceptance` on the built + media under KVM, not here. SUMMARY - name: The user-facing command run: | - # `kryptik` wraps kryptikd. It must not claim encryption the build - # cannot deliver, execute its own config file, or offer commands - # that do not work. ./compartments/tests/cli.sh - name: The launch daemon, over its socket run: | - # A runner has no XDG_RUNTIME_DIR; give it one as a session would, - # or the registry and proxy-socket checks cannot run. + # A runner has no XDG_RUNTIME_DIR, which the registry and proxy-socket checks need. uid="$(id -u)" sudo install -d -m 0700 -o "$uid" -g "$(id -g)" "/run/user/$uid" export XDG_RUNTIME_DIR="/run/user/$uid" @@ -336,8 +301,7 @@ jobs: - name: The zone boundary's regression probes run: | - # Exit status is the number of failures; checks that need root and - # the target kernel say NOT RUN and are covered by zones-test. + # Exits with the number of failures; root-only checks say NOT RUN and run in zones-test. cd compartments/kryptikd && cargo build --quiet cd ../.. && ./compartments/kryptikd/probes/boundary-checks.sh @@ -347,12 +311,10 @@ jobs: steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - # Every pinned series with a published support window is still inside it. - name: Pinned series are supported upstream run: ./tools/check-support-status.sh --strict - # Survey (network), then gate (no network). Informational on a pull - # request: an upstream release this morning is not the PR's fault. + # Survey, then gate; informational on a pull request, since an upstream release is not its fault. - name: Pins behind upstream are reviewed env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -370,8 +332,7 @@ jobs: - name: Kernel config fragments validate against pinned source run: | - # merge_config.sh drops unknown symbols silently, so every fragment - # symbol is checked against the pinned source. + # merge_config.sh drops unknown symbols silently; check each against the pinned source. mkdir -p sources # Exact names: '^linux ' would also match linux-hardened. ./tools/fetch-sources.sh --list \ @@ -384,12 +345,9 @@ jobs: ./tools/validate-kernel-config.sh --hardened ./tools/validate-kernel-config.sh --boot - - name: The resolved config honours every fragment line and kernel-hardening-checker accepts it + - name: Resolved kernel config keeps every fragment line and passes the hardening checker run: | - # Resolves the config as stage 05 does and refuses a dropped line - # (kconfig drops one silently for an unmet dependency), then holds - # kernel-hardening-checker's failures to checker-accepted.txt. - # The GCC plugin options exist only with plugin headers installed. + # The GCC plugin options exist only with the plugin headers installed. sudo apt-get install -y --no-install-recommends \ "gcc-$(gcc -dumpversion | cut -d. -f1)-plugin-dev" flex bison make check-kernel-hardening diff --git a/.shellcheckrc b/.shellcheckrc index 109105b8..f52e6962 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -1,4 +1,2 @@ -# SC2164 ("cd ... || exit"): every script sources build/lib/common.sh, whose -# `set -Eeuo pipefail` and ERR trap already abort on a failed cd. A script -# that does not source it should re-enable the check locally. +# common.sh's ERR trap aborts on a failed cd; a script that does not source it should re-enable SC2164. disable=SC2164 From 023b7841b7c7032df5c31b7f88d31793ec2067ff Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 02:07:11 -0700 Subject: [PATCH 02/14] Shorten kryptikd comments, docs and test names Comment stacks become one line, or a two-line block where the reason needs it; long doc comments are cut to a line; test names lose their sentences. Stale comments are corrected (SIT is not built, cgroup.rs sets cpu and io limits too, Landlock's oldest ABI, misplaced docs). Code changes: messages that read like essays are tightened, keeping every substring a suite or test asserts on; explain() builds its rule lines with one closure; cgroup.rs checks cgroup.procs in one helper, which its test now exercises; the unused cap::SETPCAP, a no-op tuple binding and a test that called no kryptikd code are removed. main.rs, serve.rs, broker.rs, update.rs, time.rs, zone.rs and their tests are left out: open pull requests change them. --- compartments/kryptikd/src/caps.rs | 24 +- compartments/kryptikd/src/cgroup.rs | 90 +++--- compartments/kryptikd/src/cgroup/tests.rs | 37 +-- compartments/kryptikd/src/consent.rs | 34 +-- compartments/kryptikd/src/consent/tests.rs | 12 +- compartments/kryptikd/src/files.rs | 16 +- compartments/kryptikd/src/isolate.rs | 61 ++-- compartments/kryptikd/src/isolate/tests.rs | 9 +- compartments/kryptikd/src/landlock.rs | 64 ++--- compartments/kryptikd/src/landlock/tests.rs | 20 +- compartments/kryptikd/src/netlink.rs | 38 +-- compartments/kryptikd/src/netlink/tests.rs | 22 +- compartments/kryptikd/src/netzone.rs | 77 ++--- compartments/kryptikd/src/netzone/tests.rs | 19 +- compartments/kryptikd/src/policy.rs | 16 +- compartments/kryptikd/src/policy/tests.rs | 6 +- compartments/kryptikd/src/registry.rs | 118 +++----- compartments/kryptikd/src/registry/tests.rs | 10 +- compartments/kryptikd/src/rootfs.rs | 120 +++----- compartments/kryptikd/src/rootfs/tests.rs | 23 +- compartments/kryptikd/src/seccomp.rs | 112 +++----- compartments/kryptikd/src/seccomp/tests.rs | 31 +-- compartments/kryptikd/src/serve/tests.rs | 8 +- compartments/kryptikd/src/spawn.rs | 294 +++++++------------- compartments/kryptikd/src/spawn/tests.rs | 31 +-- compartments/kryptikd/src/volume.rs | 39 +-- compartments/kryptikd/src/wifi.rs | 27 +- compartments/kryptikd/src/wifi/tests.rs | 2 +- 28 files changed, 482 insertions(+), 878 deletions(-) diff --git a/compartments/kryptikd/src/caps.rs b/compartments/kryptikd/src/caps.rs index 184705fe..09b7beae 100644 --- a/compartments/kryptikd/src/caps.rs +++ b/compartments/kryptikd/src/caps.rs @@ -1,8 +1,5 @@ -//! Capability bounding set for zones. -//! -//! Everything is dropped except `CAP_NET_BIND_SERVICE` and what the zone's -//! policy keeps from `KEEPABLE`. The network capabilities matter most: in a -//! zone that owns a veth they would let it re-address its link or forge frames. +//! Zone capability bounding set: everything is dropped but `CAP_NET_BIND_SERVICE` and what +//! the zone's policy keeps from `KEEPABLE`. use std::io; @@ -18,7 +15,6 @@ mod cap { pub const KILL: c_int = 5; pub const SETGID: c_int = 6; pub const SETUID: c_int = 7; - pub const SETPCAP: c_int = 8; pub const NET_BIND_SERVICE: c_int = 10; pub const NET_BROADCAST: c_int = 11; pub const NET_ADMIN: c_int = 12; @@ -34,15 +30,15 @@ mod cap { /// Always kept: binding a low port inside the zone's own namespace is harmless. pub const KEEP: libc::c_int = cap::NET_BIND_SERVICE; -/// What a zone policy may keep (`keep-capability CAP_X`). Each is scoped to -/// the zone's own namespace, processes or files; nothing here reaches the host. +/// What a zone policy may keep (`keep-capability CAP_X`): each is scoped to the zone's own +/// namespaces, processes or files. pub const KEEPABLE: &[libc::c_int] = &[ cap::NET_BIND_SERVICE, cap::NET_ADMIN, cap::NET_RAW, cap::NET_BROADCAST, cap::SYS_NICE, cap::IPC_LOCK, cap::KILL, cap::CHOWN, cap::FOWNER, cap::FSETID, cap::DAC_READ_SEARCH, ]; -/// Every capability number the kernel defines today, by name. +/// Capability numbers by name, as linux/capability.h defines them. pub const CAP_NAMES: &[(&str, libc::c_int)] = &[ ("CAP_CHOWN", 0), ("CAP_DAC_OVERRIDE", 1), ("CAP_DAC_READ_SEARCH", 2), ("CAP_FOWNER", 3), ("CAP_FSETID", 4), ("CAP_KILL", 5), ("CAP_SETGID", 6), ("CAP_SETUID", 7), ("CAP_SETPCAP", 8), @@ -57,7 +53,8 @@ pub const CAP_NAMES: &[(&str, libc::c_int)] = &[ ("CAP_CHECKPOINT_RESTORE", 40), ]; -/// Only the NIC zone may keep these (see `policy::check_for_zone`). +/// Only the nic zone may keep these (`policy::check_for_zone`): another zone could use them to +/// re-address its veth or forge frames. pub const NIC_ONLY: &[libc::c_int] = &[cap::NET_ADMIN, cap::NET_RAW]; pub fn cap_by_name(name: &str) -> Option { @@ -68,8 +65,7 @@ pub fn cap_name(cap: libc::c_int) -> &'static str { CAP_NAMES.iter().find(|(_, v)| *v == cap).map(|(n, _)| *n).unwrap_or("CAP_?") } -/// Highest capability the running kernel defines. Read at run time so -/// capabilities newer than our headers are dropped too. +/// Highest capability the running kernel defines, so ones newer than our table are dropped too. fn last_cap() -> libc::c_int { match std::fs::read_to_string("/proc/sys/kernel/cap_last_cap") { Ok(s) => s.trim().parse().unwrap_or(40), @@ -95,9 +91,7 @@ impl std::fmt::Display for CapError { } } -/// Drop every capability from the bounding set except `KEEP` and the -/// `KEEPABLE` ones in `keep`. Runs after the mounts and `pivot_root` (which -/// need `CAP_SYS_ADMIN`) and right before `execvp`. Any failed drop is fatal. +/// Drop every bounding-set capability but `KEEP` and the `KEEPABLE` ones in `keep`. pub fn drop_bounding_set_except(keep: &[libc::c_int]) -> Result<(), CapError> { let last = last_cap(); for cap in 0..=last { diff --git a/compartments/kryptikd/src/cgroup.rs b/compartments/kryptikd/src/cgroup.rs index c98f9bab..0a265972 100644 --- a/compartments/kryptikd/src/cgroup.rs +++ b/compartments/kryptikd/src/cgroup.rs @@ -1,8 +1,5 @@ -//! cgroup v2 memory and pid limits for a zone (`[limits]`). -//! -//! Every path either establishes the limits or reports that it could not. An -//! unprivileged launcher often cannot create cgroups at all, so `available()` -//! finds out by trying rather than by checking the uid. +//! cgroup v2 memory, pid, cpu and io limits for a zone (`[limits]`), never silently left unset. +//! `available()` finds out by trying: an unprivileged launcher often cannot create cgroups. use std::fs; use std::io; @@ -56,10 +53,8 @@ pub fn parse_io_max(v: &str) -> Result { crate::zone::parse_size(v).ok_or_else(|| CgroupError::BadLimit { field: "io_max", value: v.to_string() }) } -/// The `MAJ:MIN` of the block device at `path`, then of the devices it is -/// built on (`slaves` in sysfs): io.max names devices, and an encrypted -/// volume's bytes land on the device under its mapping. A partition among -/// them stands for its disk, the only thing io.max takes. +/// `MAJ:MIN` of the block device at `path` and of the disks under it (sysfs `slaves`), where an +/// encrypted volume's bytes land; a partition counts as its disk, the only thing io.max takes. pub fn block_devices(path: &str) -> io::Result> { use std::os::unix::fs::{FileTypeExt, MetadataExt}; let md = fs::metadata(path)?; @@ -81,8 +76,7 @@ pub fn block_devices(path: &str) -> io::Result> { Ok(out) } -/// A sysfs block entry's `MAJ:MIN`, or its disk's when the entry is a -/// partition (`..` of a partition's directory is the disk's). +/// A sysfs block entry's `MAJ:MIN`, or its disk's (the parent directory) for a partition. fn whole_device(entry: &Path) -> Option { let dir = if entry.join("partition").exists() { entry.join("..") } else { entry.to_path_buf() }; let dev = fs::read_to_string(dir.join("dev")).ok()?; @@ -94,8 +88,7 @@ fn read_trim(p: &Path) -> Result { Ok(fs::read_to_string(p).map_err(|e| io_err(p, e))?.trim().to_string()) } -/// Make sure `dir` delegates `NEEDED` to its children. Without that, a child -/// is created without error but has no `memory.max`. +/// Make `dir` delegate `NEEDED`, or a child is created without error but has no `memory.max`. fn ensure_subtree_control(dir: &Path) -> Result<(), CgroupError> { let avail_path = dir.join("cgroup.controllers"); let avail = read_trim(&avail_path)?; @@ -143,14 +136,12 @@ pub fn available_under(root: &Path) -> Result { } ensure_subtree_control(&group)?; - /* The group may exist but be root's, made by a privileged launch, so prove - * a leaf can be created, as a launch will, and remove it. */ + // The group may be root's, from a privileged launch: prove a leaf can be made, then remove it. let probe = group.join(format!(".probe.{}", std::process::id())); let _ = fs::remove_dir(&probe); if let Err(e) = fs::create_dir(&probe) { return Err(CgroupError::Unavailable(format!( - "cannot create a cgroup under {} ({e}), so this launcher could not put a \ - zone in one", + "cannot create a cgroup under {} ({e})", group.display() ))); } @@ -166,12 +157,10 @@ pub fn available_under(root: &Path) -> Result { Ok(group) } -/// Age after which an empty leaf with no launcher pid in its name is abandoned. -/// A live launch's leaf is empty for the microseconds before the attach. +/// Age at which an empty pid-less leaf is abandoned; a live launch's is empty for microseconds. const STALE_AFTER: Duration = Duration::from_secs(5); -/// Remove the empty leaves of launchers killed before `Cgroup::drop` could run. -/// A populated leaf belongs to a live zone and is never touched. +/// Remove empty leaves whose launcher died before `Cgroup::drop`; populated ones are left alone. fn sweep_stale(base: &Path) { for p in abandoned_leaves(base) { // EBUSY for a moment while the last task exits; retry, then report. @@ -204,10 +193,8 @@ fn abandoned_leaves(base: &Path) -> Vec { if !p.is_dir() { continue; } - /* Leaves are named .; a leaf whose launcher is gone - * is abandoned, and a live (or reused) pid keeps it. Age is no guide: - * kernfs (6.18) dates a cgroup directory from its first stat, not its - * mkdir. The age rule covers only names without a pid. */ + /* A . leaf is abandoned once its launcher is gone. kernfs (6.18) dates a + * cgroup directory from its first stat, not its mkdir, so only pid-less names go by age. */ let abandoned = match launcher_of(&p) { Some(pid) => !Path::new(&format!("/proc/{pid}")).exists(), None => e @@ -217,28 +204,28 @@ fn abandoned_leaves(base: &Path) -> Vec { .and_then(|m| now.duration_since(m).ok()) .is_some_and(|age| age > STALE_AFTER), }; - if !abandoned { - continue; - } - let populated = fs::read_to_string(p.join("cgroup.procs")) - .map(|s| s.lines().any(|l| !l.trim().is_empty())) - .unwrap_or(true); // unreadable: assume live, leave it alone - if !populated { + if abandoned && !populated(&p) { out.push(p); } } out } +/// Whether a leaf holds a process; an unreadable `cgroup.procs` counts, so the leaf is left alone. +fn populated(leaf: &Path) -> bool { + fs::read_to_string(leaf.join("cgroup.procs")) + .map(|s| s.lines().any(|l| !l.trim().is_empty())) + .unwrap_or(true) +} + /// The launcher pid a leaf is named after (`.`), if it is. fn launcher_of(leaf: &Path) -> Option { leaf.file_name()?.to_str()?.rsplit_once('.')?.1.parse().ok() } -/// Remove every empty per-zone cgroup, for `gc`; returns how many. rmdir of a -/// populated cgroup fails with EBUSY, so running zones are safe. +/// Remove every empty zone cgroup for `gc` and count them; rmdir of a live one fails with EBUSY. pub fn sweep_now() -> usize { - let root = Path::new(CGROUP2_ROOT).join(KRYPTIK_GROUP); + let root = kryptik_root(); let Ok(entries) = fs::read_dir(&root) else { return 0 }; let mut n = 0; for e in entries.flatten() { @@ -246,18 +233,14 @@ pub fn sweep_now() -> usize { if !p.is_dir() { continue; } - let populated = fs::read_to_string(p.join("cgroup.procs")) - .map(|s| s.lines().any(|l| !l.trim().is_empty())) - .unwrap_or(true); - if !populated && fs::remove_dir(&p).is_ok() { + if !populated(&p) && fs::remove_dir(&p).is_ok() { n += 1; } } n } -/// The directory every zone's cgroup is created under. `registry::reclaim` -/// checks a recorded cgroup path against it before writing `cgroup.kill`. +/// Where zone cgroups live; `registry::reclaim` checks a recorded path is under it before a kill. pub fn kryptik_root() -> PathBuf { Path::new(CGROUP2_ROOT).join(KRYPTIK_GROUP) } @@ -269,8 +252,7 @@ pub struct Cgroup { } impl Cgroup { - /// Create the leaf for one launch, named with the launcher's pid: two - /// overlapping launches sharing one limit could starve each other. + /// One launch's leaf, named with the launcher's pid so overlapping launches share no limit. pub fn create(base: &Path, zone: &str, launcher_pid: i32) -> Result { let path = base.join(format!("{zone}.{launcher_pid}")); // Never adopt a leftover leaf, and its limits, from a reused pid. @@ -283,8 +265,7 @@ impl Cgroup { &self.path } - /// Write the limits; an absent one is written as `max`, not left as - /// inherited. `io_max` names the volume's devices and the bytes per second. + /// An absent limit is written as `max`, not inherited; `io_max` is (devices, bytes per second). pub fn set_limits( &self, memory_max: Option<&str>, @@ -311,7 +292,7 @@ impl Cgroup { } } - // cpu.max is a quota per 100 ms period: a percentage of one CPU is that many thousand microseconds. + // cpu.max is a quota per 100 ms period: N% of one CPU is N thousand microseconds. let cpu = self.path.join("cpu.max"); match cpu_max { Some(v) => { @@ -332,20 +313,17 @@ impl Cgroup { } } - /* An OOM kills the whole zone at once, so no survivors keep its files - * and sockets open. */ + // An OOM kills the whole zone, so no survivors keep its files and sockets open. let og = self.path.join("memory.oom.group"); fs::write(&og, "1").map_err(|e| io_err(&og, e))?; - /* Cap swap too, or the zone pages out past its memory limit. ENOENT - * means no swap accounting in this kernel (cgroupfs never creates - * files), so warn; any other write failure is fatal. */ + /* Cap swap too, or the zone pages out past its memory limit. ENOENT means no swap + * accounting (cgroupfs never creates files), so warn; any other failure is fatal. */ let sw = self.path.join("memory.swap.max"); match fs::write(&sw, "0") { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::NotFound => eprintln!( - "kryptikd: {} does not exist - this kernel has no swap accounting, \ - so the zone's memory limit does not bound what it can page out", + "kryptikd: no swap accounting ({} is missing): the zone can page out past its memory limit", sw.display() ), Err(e) => return Err(io_err(&sw, e)), @@ -353,15 +331,13 @@ impl Cgroup { Ok(()) } - /// Move a process into this cgroup. Must happen before it unshares its - /// cgroup namespace, so the namespace is rooted here. + /// Move a process in before it unshares its cgroup namespace, so the namespace is rooted here. pub fn attach(&self, pid: i32) -> Result<(), CgroupError> { let p = self.path.join("cgroup.procs"); fs::write(&p, pid.to_string()).map_err(|e| io_err(&p, e)) } - /// Kill everything inside (`cgroup.kill`, Linux 5.14+), then remove the - /// directory, retrying for 1 s while the processes exit. + /// Kill all inside (`cgroup.kill`, Linux 5.14+), then retry removing the directory for 1 s. pub fn destroy(&self) -> Result<(), CgroupError> { let _ = fs::write(self.path.join("cgroup.kill"), "1"); for _ in 0..50 { diff --git a/compartments/kryptikd/src/cgroup/tests.rs b/compartments/kryptikd/src/cgroup/tests.rs index b70f5590..8bd6d05f 100644 --- a/compartments/kryptikd/src/cgroup/tests.rs +++ b/compartments/kryptikd/src/cgroup/tests.rs @@ -1,8 +1,7 @@ use super::*; use std::os::unix::fs::PermissionsExt; -/// A readable, delegated group this process cannot create leaves in is -/// not available. +/// A readable, delegated group this process cannot create leaves in is not available. #[test] fn availability_requires_creating_leaf() { let root = std::env::temp_dir().join(format!("kryptik-cg-{}", std::process::id())); @@ -58,7 +57,7 @@ fn cpu_and_io_limits_parse() { } #[test] -fn a_partition_stands_for_its_disk() { +fn partition_counts_as_disk() { // A disk's sysfs directory with a partition's inside it, as the kernel lays them out. let disk = std::env::temp_dir().join(format!("kryptik-disk-{}", std::process::id())); let part = disk.join("sda3"); @@ -82,13 +81,15 @@ fn overflowing_limit_refused() { #[test] fn sweep_spares_populated_cgroup() { - // Checks the cgroup.procs parsing; a real hierarchy may be out of reach. - let populated = |s: &str| s.lines().any(|l| !l.trim().is_empty()); - assert!(populated("1234\n")); - assert!(populated("1234\n5678\n")); - assert!(!populated("")); - assert!(!populated("\n")); - assert!(!populated(" \n")); + // A plain directory stands in for the leaf; /sys/fs/cgroup may be out of reach. + let leaf = std::env::temp_dir().join(format!("kryptik-procs-{}", std::process::id())); + fs::create_dir_all(&leaf).unwrap(); + for (procs, live) in [("1234\n", true), ("1234\n5678\n", true), ("", false), ("\n", false), (" \n", false)] { + fs::write(leaf.join("cgroup.procs"), procs).unwrap(); + assert_eq!(populated(&leaf), live, "{procs:?}"); + } + let _ = fs::remove_dir_all(&leaf); + assert!(populated(&leaf), "an unreadable cgroup.procs counts as populated"); } #[test] @@ -115,9 +116,7 @@ fn sweep_goes_by_launcher_pid() { assert_eq!(launcher_of(&gone), Some(dead)); assert_eq!(launcher_of(&live), Some(alive as i32)); assert_eq!(launcher_of(&unnamed), None); - /* Only the dead launcher's empty leaf goes. A live launcher's is a - * launch in progress, a populated leaf is never touched, and the - * pid-less one is young by the age rule. */ + // Only the dead launcher's empty leaf goes; live, busy and young pid-less leaves stay. assert_eq!(abandoned_leaves(&base), vec![gone.clone()]); assert!(live.exists() && busy.exists() && unnamed.exists()); let _ = fs::remove_dir_all(&base); @@ -142,8 +141,7 @@ fn available_leaves_no_probes() { #[test] fn unwritable_limit_is_error() { - /* A read-only directory holding writable memory.max and pids.max, so - * the failing write is memory.oom.group's, which must be an error. */ + // A read-only directory with only memory.max and pids.max, so memory.oom.group's write fails. let dir = std::env::temp_dir().join(format!("kryptik-cgtest-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); @@ -172,12 +170,3 @@ fn unwritable_limit_is_error() { "set_limits must fail naming the limit it could not apply, said: {msg}" ); } - -#[test] -fn leaf_name_unique_per_launch() { - // Overlapping launches of one zone must not share a limit. - let base = Path::new("/sys/fs/cgroup/kryptik"); - let a = base.join(format!("{}.{}", "work", 111)); - let b = base.join(format!("{}.{}", "work", 222)); - assert_ne!(a, b); -} diff --git a/compartments/kryptikd/src/consent.rs b/compartments/kryptikd/src/consent.rs index a677b716..300627a3 100644 --- a/compartments/kryptikd/src/consent.rs +++ b/compartments/kryptikd/src/consent.rs @@ -5,9 +5,8 @@ //! /run/kryptik-consent/.code the code the window asks for (the chrome's) //! /run/kryptik-consent/.answer yes | no (written by the chrome) //! -//! No zone can reach the directory (root:kryptik 2770, made by sysinit), but -//! the session's group can write to it, so nothing found there is trusted. -//! Anything but a plain-file `yes` before the deadline is a refusal. +//! No zone reaches the directory (root:kryptik 2770, made by sysinit), but the session's +//! group writes to it: anything but a plain-file `yes` before the deadline is a refusal. use std::ffi::CString; use std::io::{self, Read, Write}; @@ -42,8 +41,7 @@ fn timeout() -> Duration { .unwrap_or(Duration::from_secs(TIMEOUT_SECS)) } -/// Open the channel directory without following a link; every later access -/// is relative to it. It must be root's (or ours) and not world-writable. +/// Open the channel directory, no symlink followed: root's (or ours) and not world-writable. fn open_channel(d: &Path) -> Result { use std::os::unix::ffi::OsStrExt; let c = CString::new(d.as_os_str().as_bytes()) @@ -51,7 +49,7 @@ fn open_channel(d: &Path) -> Result { let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) }; if fd < 0 { return Err(format!( - "no consent channel at {}: nothing in zone 0 can approve this transfer ({})", + "no consent channel at {}: nothing in zone 0 can approve this request ({})", d.display(), io::Error::last_os_error() )); @@ -105,9 +103,9 @@ fn nonce() -> Result { Ok(u64::from_ne_bytes(b)) } -/// Whether the chrome is watching: its lock is a plain file that someone -/// holds exclusively. O_NONBLOCK keeps a FIFO at that name from hanging us. +/// Whether the chrome is watching: someone holds its lock, a plain file, exclusively. fn watched(dfd: RawFd) -> bool { + // O_NONBLOCK: a FIFO at that name must not hang us. let Ok(f) = open_entry(dfd, WATCHER_LOCK, libc::O_RDONLY | libc::O_NONBLOCK, 0) else { return false }; let Ok(st) = stat_of(f.as_raw_fd()) else { return false }; if !is_regular(&st) { @@ -119,9 +117,8 @@ fn watched(dfd: RawFd) -> bool { io::Error::last_os_error().raw_os_error() == Some(libc::EWOULDBLOCK) } -/// Write the question to a fresh O_EXCL temporary, skipping taken names, make -/// it group-readable for the chrome, and rename it to `.ask`. The id's -/// random nonce means no answer can be waiting under it in advance. +/// Write the question to an O_EXCL temporary, group-readable for the chrome, and rename it to +/// `.ask`. The id's random nonce means no answer can be waiting under it in advance. fn place_question(dfd: RawFd, text: &str) -> Result { for _ in 0..8 { let id = format!("{}-{}-{:016x}", std::process::id(), COUNTER.fetch_add(1, Ordering::SeqCst), nonce()?); @@ -171,8 +168,7 @@ fn read_answer(dfd: RawFd, name: &str) -> Result, String> { Ok(Some(String::from_utf8_lossy(&buf).into_owned())) } -/// Why a question got no `yes`, and whether the user saw it: only a question -/// placed took focus in zone 0, so only that one pauses the next. +/// Why a question got no `yes`, and whether it was shown (only a shown one pauses the next). #[derive(Debug)] pub struct Refusal { pub why: String, @@ -198,8 +194,7 @@ impl std::fmt::Display for Refusal { } } -/// May this file cross? `Ok(())` only on an explicit `yes`. `asking` runs ten -/// times a second while the question is open; `false` withdraws it. +/// May this file cross? `asking` runs ten times a second while it is open; `false` withdraws it. pub fn ask(from: &str, to: &str, name: &str, bytes: u64, asking: &dyn Fn() -> bool) -> Result<(), Refusal> { ask_text(&format!("from={from}\nto={to}\nname={name}\nbytes={bytes}\n"), asking) } @@ -209,8 +204,7 @@ pub fn keep() -> bool { true } -/// May the clock be set? Asked past the unasked bound (docs/design/time.md). -/// `kind=clock` tells the chrome which question to draw; no kind is a transfer. +/// May the clock be set (docs/design/time.md)? `kind=clock` tells the chrome what to draw. pub fn ask_clock(now: &str, proposed: &str, sources: u8, asking: &dyn Fn() -> bool) -> Result<(), String> { ask_text(&format!("kind=clock\nnow={now}\nproposed={proposed}\nsources={sources}\n"), asking).map_err(|r| r.why) } @@ -222,8 +216,7 @@ fn ask_text(text: &str, asking: &dyn Fn() -> bool) -> Result<(), Refusal> { let dfd = channel.as_raw_fd(); if !watched(dfd) { return Err(Refusal::unseen(format!( - "no consent channel: nothing in zone 0 is watching {} (no trusted window to ask); \ - refused for want of consent", + "no consent channel: no trusted window in zone 0 is watching {}; refused", d.display() ))); } @@ -247,8 +240,7 @@ fn ask_text(text: &str, asking: &dyn Fn() -> bool) -> Result<(), Refusal> { if Instant::now() >= deadline { break Err(format!("no answer from zone 0 within {} s; treated as a refusal", timeout().as_secs())); } - /* The asker's turn: this wait holds up its launcher's loop, which - * relays the zone's output and notices the zone ending. */ + // This wait holds its launcher's loop: `asking` relays the zone's output and sees it end. if !asking() { break Err("the asking zone went away while the question was open; withdrawn".to_string()); } diff --git a/compartments/kryptikd/src/consent/tests.rs b/compartments/kryptikd/src/consent/tests.rs index 7b458060..e0abe05b 100644 --- a/compartments/kryptikd/src/consent/tests.rs +++ b/compartments/kryptikd/src/consent/tests.rs @@ -13,8 +13,7 @@ fn env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } -/// Hold the watcher lock while the returned file lives. Children forked by -/// other tests inherit the flock, so no test may rely on its release. +/// Hold the watcher lock while the file lives. No test may rely on its release: forks inherit it. fn hold_watch(d: &std::path::Path) -> std::fs::File { use std::os::unix::io::AsRawFd; let f = std::fs::File::create(d.join(WATCHER_LOCK)).unwrap(); @@ -75,8 +74,7 @@ fn silence_and_missing_channel_refuse() { with_dir(|d| { std::env::set_var("KRYPTIK_CONSENT_DIR", d); std::env::set_var("KRYPTIK_CONSENT_TIMEOUT", "1"); - /* Nobody watching (no lock file, then one nobody holds): refused at - * once, and no question is left behind. */ + // Nobody watching (no lock file, then an unheld one): refused at once, nothing left behind. for lock_file in [false, true] { if lock_file { std::fs::File::create(d.join(WATCHER_LOCK)).unwrap(); @@ -122,8 +120,7 @@ fn vanished_sender_withdraws_question() { }); } -/// A group member plants symlinks and a ready "yes" under the pid-counter -/// names: root must not write through them or believe the answer. +/// Symlinks and a ready "yes" planted under the pid-counter names: neither written nor believed. #[test] fn planted_names_are_ignored() { use std::os::unix::fs::symlink; @@ -148,8 +145,7 @@ fn planted_names_are_ignored() { }); } -/// A FIFO at the answer's name must not hang the broker, and a symlink to -/// a "yes" must not be followed. +/// A FIFO at the answer's name must not hang the broker, nor a symlink to a "yes" be followed. #[test] fn non_file_answer_refused_promptly() { use std::os::unix::fs::symlink; diff --git a/compartments/kryptikd/src/files.rs b/compartments/kryptikd/src/files.rs index e13129d7..10f47c5e 100644 --- a/compartments/kryptikd/src/files.rs +++ b/compartments/kryptikd/src/files.rs @@ -1,5 +1,4 @@ -//! Zone 0's state files, written whole: the update channel's, the Wi-Fi -//! file, the clipboard and the clock's. +//! Zone 0's state files, written whole: update channel, Wi-Fi, clipboard and clock. use std::fs; use std::io::{self, Write}; @@ -7,16 +6,13 @@ use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsE use std::os::unix::io::AsRawFd; use std::path::Path; -/// Replace `path` with `parts`, whole: a temporary named for this process, -/// O_EXCL|O_NOFOLLOW with exactly `mode` and owned as asked, synced, renamed -/// over the file, then the directory synced where the filesystem allows. A -/// failure leaves the old file and no temporary. +/// Replace `path` with `parts` through a synced O_EXCL|O_NOFOLLOW temporary named for this +/// process; a failure leaves the old file and no temporary. pub fn write_atomic(path: &Path, parts: &[&[u8]], mode: u32, owner: Option<(u32, u32)>) -> io::Result<()> { let name = path.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no file name"))?; let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let stem = format!(".{}.", name.to_string_lossy()); - /* A writer that died before its rename leaves its temporary, perhaps - * with a secret in it (the Wi-Fi file). */ + // A writer that died before its rename left its temporary, perhaps holding a secret. for e in fs::read_dir(dir)?.flatten() { let n = e.file_name(); let pid = n.to_str().and_then(|s| s.strip_prefix(stem.as_str())).and_then(|p| p.parse::().ok()); @@ -55,9 +51,7 @@ pub fn write_atomic(path: &Path, parts: &[&[u8]], mode: u32, owner: Option<(u32, Ok(()) } -/// Make or find a directory of this user's alone. One that was there is -/// looked at: a link, another owner, or a mode that lets anyone else in is -/// refused. +/// Make or find a directory of this user's alone; a link, another owner or an open mode is refused. pub fn private_dir(p: &Path) -> io::Result<()> { match fs::DirBuilder::new().recursive(true).mode(0o700).create(p) { Ok(()) => {} diff --git a/compartments/kryptikd/src/isolate.rs b/compartments/kryptikd/src/isolate.rs index 76c1eab4..98436cd5 100644 --- a/compartments/kryptikd/src/isolate.rs +++ b/compartments/kryptikd/src/isolate.rs @@ -1,12 +1,11 @@ -//! Zone isolation primitives: namespaces and id maps, core scheduling, and -//! probes of what the kernel provides. Direct `libc` calls (ADR-010). +//! Zone isolation primitives as direct libc calls (ADR-010): namespaces, id maps, core +//! scheduling, and probes of what the kernel provides. use std::io; use crate::zone::{NetworkMode, Zone}; -/// Namespaces every zone gets. The user namespace lets the others be created -/// and makes root inside the zone weaker than root outside it. +/// Namespaces every zone gets; the user namespace makes the zone's root weaker than the host's. pub const ZONE_NAMESPACES: libc::c_int = libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWPID @@ -46,9 +45,6 @@ fn check(call: &'static str, ret: libc::c_int) -> Result<(), IsolateError> { } } -/// Namespace flags for a zone. Every zone gets its own network namespace; the -/// nic zone's is where the parent moves the physical NIC (netzone), leaving -/// zone 0 with loopback. /// The namespaces a zone can be given, by name, in the order reports list them. pub const NAMESPACES: [(libc::c_int, &str); 7] = [ (libc::CLONE_NEWUSER, "user"), @@ -64,22 +60,20 @@ pub fn namespace_names(flags: libc::c_int) -> Vec<&'static str> { NAMESPACES.iter().filter(|(f, _)| flags & f != 0).map(|(_, n)| *n).collect() } +/// Every zone gets its own network namespace; the nic zone's receives the physical NIC. pub fn namespace_flags(zone: &Zone) -> libc::c_int { match zone.network { NetworkMode::None | NetworkMode::Routed | NetworkMode::Nic => ZONE_NAMESPACES | NS_NET, } } -/// Enter new namespaces. CLONE_NEWPID puts the caller's children in the new pid -/// namespace, not the caller, so the caller must fork afterwards. +/// Enter new namespaces; CLONE_NEWPID applies to the caller's children, so fork afterwards. pub fn unshare_namespaces(flags: libc::c_int) -> Result<(), IsolateError> { check("unshare", unsafe { libc::unshare(flags) }) } -/// Write the uid/gid maps for a new user namespace. setgroups is denied first, -/// or the kernel refuses an unprivileged gid_map write. `with_nobody` also maps -/// nobody (65534) into the zone's range, which needs CAP_SETUID in the parent -/// namespace: privileged launch only. +/// Write a new user namespace's id maps, denying setgroups first as an unprivileged gid_map +/// needs. `with_nobody` maps 65534 too, which needs CAP_SETUID: a root launch only. pub fn write_id_maps( pid: libc::pid_t, outer_uid: u32, @@ -111,9 +105,8 @@ pub fn write_id_maps( Ok(()) } -/// Whether a sysctl restricts unprivileged user namespaces, and which: -/// linux-hardened's `unprivileged_userns_clone` (0 = restricted) or Ubuntu's -/// `apparmor_restrict_unprivileged_userns` (1 = restricted). `None`: neither. +/// Whether a sysctl restricts unprivileged user namespaces, and which: linux-hardened's +/// `unprivileged_userns_clone` (0) or Ubuntu's `apparmor_restrict_unprivileged_userns` (1). pub fn userns_restriction_sysctl() -> Option<(bool, &'static str)> { let read = |p: &str| std::fs::read_to_string(p).ok().and_then(|s| s.trim().parse::().ok()); if let Some(v) = read("/proc/sys/kernel/unprivileged_userns_clone") { @@ -125,8 +118,7 @@ pub fn userns_restriction_sysctl() -> Option<(bool, &'static str)> { None } -/// Test the restriction rather than read it: in a child, as uid 65534 if we are -/// root, try `unshare(CLONE_NEWUSER)`. `Ok(true)` means EPERM (restricted). +/// Try `unshare(CLONE_NEWUSER)` in a child, as uid 65534 if we are root; `Ok(true)` is EPERM. pub fn probe_userns_restriction() -> Result { let pid = unsafe { libc::fork() }; if pid < 0 { @@ -175,8 +167,7 @@ pub fn probe_userns_restriction() -> Result { } } -/// Die (SIGKILL) when the parent exits. The kernel clears this on fork and on -/// any euid or egid change (setresuid), so set it again after either. +/// SIGKILL when the parent exits; fork and any euid or egid change clear it, so re-arm after. pub fn die_with_parent() -> Result<(), IsolateError> { check( "prctl(PR_SET_PDEATHSIG)", @@ -190,9 +181,8 @@ const PR_SCHED_CORE_GET: libc::c_ulong = 0; const PR_SCHED_CORE_CREATE: libc::c_ulong = 1; const PIDTYPE_PID: libc::c_ulong = 0; -/// Give the calling task its own core-scheduling cookie, which its children -/// inherit: a core's sibling threads then run it only alongside the same -/// cookie. Needs no privilege. ENODEV and EINVAL are not failures (`CoreSched`). +/// Give the calling task, and so its children, its own core-scheduling cookie: a core's +/// siblings then run it only beside the same cookie. ENODEV and EINVAL: see `CoreSched`. pub fn take_core_cookie() -> io::Result<()> { if unsafe { libc::prctl(PR_SCHED_CORE, PR_SCHED_CORE_CREATE, 0, PIDTYPE_PID, 0) } < 0 { return Err(io::Error::last_os_error()); @@ -205,15 +195,13 @@ pub fn take_core_cookie() -> io::Result<()> { pub enum CoreSched { /// Sibling threads are online and scheduled by cookie. Cookies, - /// ENODEV: no sibling thread online (`nosmt`, ADR-011, or no SMT at all). - /// Nothing shares a core, which is what the cookie is for. + /// ENODEV: no sibling thread online (`nosmt`, ADR-011), so nothing shares a core. NoSmt, /// EINVAL: a kernel built without CONFIG_SCHED_CORE. Unavailable, } -/// Asks by reading our own cookie: while no sibling thread is online, every -/// PR_SCHED_CORE operation answers ENODEV first. +/// Read our own cookie: with no sibling thread online, every PR_SCHED_CORE call gives ENODEV. pub fn core_scheduling() -> CoreSched { match core_cookie_of(0) { Ok(_) => CoreSched::Cookies, @@ -222,8 +210,7 @@ pub fn core_scheduling() -> CoreSched { } } -/// A task's core-scheduling cookie, 0 if none; pid 0 is the calling task. -/// /proc does not show it. Another task's needs ptrace-read access to it. +/// A task's core-scheduling cookie, 0 if none (pid 0: ours); another's needs ptrace-read access. pub fn core_cookie_of(pid: libc::pid_t) -> io::Result { let mut cookie: libc::c_ulong = 0; let rc = unsafe { @@ -246,17 +233,15 @@ pub fn core_cookie_word(pid: libc::pid_t) -> &'static str { } } -/// Set the hostname in the current UTS namespace. Needs CAP_SYS_ADMIN in the -/// owning user namespace, which the zone's root has once the id maps exist. +/// Set the hostname in the current UTS namespace (CAP_SYS_ADMIN there, once the id maps exist). pub fn set_hostname(name: &str) -> Result<(), IsolateError> { check("sethostname", unsafe { libc::sethostname(name.as_ptr() as *const libc::c_char, name.len()) }) } -/// Drop every supplementary group. Needs CAP_SETGID in the current user -/// namespace, so an unprivileged kryptikd gets EPERM, and setgroups is denied -/// inside the zone's namespace. +/// Drop every supplementary group: needs CAP_SETGID (an unprivileged kryptikd gets EPERM), and +/// setgroups is denied inside the zone's namespace. pub fn drop_supplementary_groups() -> Result<(), IsolateError> { check("setgroups", unsafe { libc::setgroups(0, std::ptr::null()) }) } @@ -267,8 +252,7 @@ pub fn supplementary_group_count() -> usize { if n < 0 { 0 } else { n as usize } } -/// Bring up `lo` in the zone's network namespace. Even an air-gapped zone needs -/// it, and it reaches nothing outside the namespace. +/// Bring up `lo`: even an air-gapped zone needs it, and it reaches nothing outside. pub fn bring_up_loopback() -> Result<(), IsolateError> { let sock = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) }; if sock < 0 { @@ -285,9 +269,8 @@ pub fn bring_up_loopback() -> Result<(), IsolateError> { } ifr.ifr_ifru.ifru_flags = (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short; - /* `as _`: the request is c_ulong in glibc and c_int in musl (the static - * initramfs build). The kernel reads 32 bits and the assert checks the - * value fits them, so narrowing to c_int loses nothing. */ + /* `as _`: the request is c_ulong in glibc and c_int in musl (the initramfs build); the + * kernel reads 32 bits, and the assert checks the value fits them. */ const _: () = assert!((libc::SIOCSIFFLAGS as u64) <= u32::MAX as u64); let ret = unsafe { libc::ioctl(sock, libc::SIOCSIFFLAGS as _, &ifr) }; unsafe { libc::close(sock) }; diff --git a/compartments/kryptikd/src/isolate/tests.rs b/compartments/kryptikd/src/isolate/tests.rs index ac554f49..70f4b401 100644 --- a/compartments/kryptikd/src/isolate/tests.rs +++ b/compartments/kryptikd/src/isolate/tests.rs @@ -9,10 +9,9 @@ fn zone(mode: &str) -> Zone { .unwrap() } -/// Taken in a forked child and read from the parent, as `kryptikd status` -/// reads a zone's; the test process itself keeps no cookie. +/// Taken in a forked child and read from the parent, as `kryptikd status` reads a zone's. #[test] -fn task_takes_own_core_cookie() { +fn own_core_cookie() { let me = unsafe { libc::getpid() }; match core_scheduling() { CoreSched::Cookies => {} @@ -63,7 +62,7 @@ fn task_takes_own_core_cookie() { } #[test] -fn every_zone_gets_net_namespace() { +fn net_namespace_always() { assert_ne!(namespace_flags(&zone("none")) & libc::CLONE_NEWNET, 0); assert_ne!(namespace_flags(&zone("routed")) & libc::CLONE_NEWNET, 0); // The parent moves the physical NIC into this one. @@ -71,7 +70,7 @@ fn every_zone_gets_net_namespace() { } #[test] -fn every_zone_gets_core_namespaces() { +fn core_namespaces_always() { for m in ["none", "routed", "nic"] { let f = namespace_flags(&zone(m)); for (flag, name) in &NAMESPACES[..5] { diff --git a/compartments/kryptikd/src/landlock.rs b/compartments/kryptikd/src/landlock.rs index 92e9a1cc..75f87473 100644 --- a/compartments/kryptikd/src/landlock.rs +++ b/compartments/kryptikd/src/landlock.rs @@ -1,8 +1,5 @@ -//! Landlock filesystem confinement (ADR-007): self-applied, irreversible, and -//! needing no privilege. For a zone it backs up the pivoted root (`rootfs`). -//! -//! Rules are additive: a rule on a sub-path can only add rights, so `/` gets -//! the fewest and every writable place is named (`zone_rules`). +//! Landlock filesystem confinement (ADR-007), backing up a zone's pivoted root. Rules are +//! additive, so `/` gets the fewest rights and every writable place is named (`zone_rules`). use std::ffi::CString; use std::io; @@ -33,13 +30,11 @@ const FS_REFER: u64 = 1 << 13; // ABI v2 const FS_TRUNCATE: u64 = 1 << 14; // ABI v3 const FS_IOCTL_DEV: u64 = 1 << 15; // ABI v5 -/// Oldest ABI a zone may run on (Linux 6.2). Below it the kernel cannot handle -/// REFER (v2) or TRUNCATE (v3), so the policy would not mean what it says. +/// Oldest ABI a zone may run on (Linux 6.2), the first to restrict both REFER and TRUNCATE. pub const MIN_ABI: i32 = 3; pub const ACCESS_READ: u64 = FS_READ_FILE | FS_READ_DIR; -/// Full write. Without REFER, `mv a dir/` fails with EXDEV; without TRUNCATE, -/// `>` on an existing file fails. +/// Full write: without REFER `mv a dir/` fails with EXDEV, without TRUNCATE `>` fails. pub const ACCESS_WRITE: u64 = FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE @@ -93,9 +88,8 @@ impl std::fmt::Display for LandlockError { ), LandlockError::TooOld { abi, need } => write!( f, - "landlock ABI v{abi} is too old (need v{need}, Linux 6.2+): the kernel \ - cannot express truncate/rename restrictions, so the zone policy \ - would silently mean less than it says" + "landlock ABI v{abi} is too old (need v{need}, Linux 6.2+): it cannot restrict \ + everything a zone policy says" ), LandlockError::Syscall { call, errno } => { write!(f, "{call}: {}", io::Error::from_raw_os_error(*errno)) @@ -150,8 +144,7 @@ fn access_mask_for(abi: i32) -> u64 { mask } -/// A ruleset under construction. Every handled access is denied unless a rule -/// allows it. +/// A ruleset under construction: every handled access is denied unless a rule allows it. pub struct Ruleset { fd: RawFd, abi: i32, @@ -196,10 +189,8 @@ impl Ruleset { Ok(Ruleset { fd: fd as RawFd, abi }) } - /// Allow `access` on everything beneath `path`. A missing path is an error, - /// so a typo cannot silently change confinement. So is a symbolic link - /// anywhere in it: a zone that swapped a granted directory for a link to a - /// wider one would otherwise widen its own rule at its next start. + /// Allow `access` beneath `path`. A missing path is an error, and so is a symlink anywhere + /// in it: a zone could swap a granted directory for a link to a wider one. pub fn allow(&mut self, path: &str, access: u64) -> Result<(), LandlockError> { let fd = open_exact(path)?; @@ -229,8 +220,7 @@ impl Ruleset { Ok(()) } - /// Apply the ruleset to this process and every descendant. Irreversible. - /// Sets `PR_SET_NO_NEW_PRIVS` first; without it restrict_self is EPERM. + /// Set no_new_privs, without which this is EPERM, and apply the ruleset irreversibly. pub fn restrict_self(self) -> Result<(), LandlockError> { let nnp = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; if nnp < 0 { @@ -253,9 +243,8 @@ impl Ruleset { } } -/// An O_PATH descriptor for `path` as named: openat2 refuses a symbolic link -/// in any component (ELOOP). It is older (Linux 5.6) than any kernel with the -/// ABI a ruleset needs. +/// An O_PATH descriptor for `path` as named: openat2 refuses a symlink in any component +/// (ELOOP), and is older (Linux 5.6) than any kernel with the ABI a ruleset needs. fn open_exact(path: &str) -> Result { let c = CString::new(path).map_err(|_| LandlockError::BadPath { path: path.into(), errno: libc::EINVAL })?; // Filled in, not built: libc marks open_how non_exhaustive. @@ -282,10 +271,8 @@ fn open_exact(path: &str) -> Result { Ok(fd as RawFd) } -/// Directives of a zone's Landlock policy file (`[policy] landlock`): one -/// `directive /path` per line, paths as the zone sees them. The file is a second -/// Landlock layer, and layers intersect, so it can only narrow the base rules. -/// There is no `deny`: Landlock has no subtraction. +/// Directives of a `[policy] landlock` file, one `directive /path` per line. The file is a +/// second layer, which can only narrow the base rules, so there is no `deny`. pub const FS_DIRECTIVES: &[(&str, u64)] = &[ ("read", ACCESS_READ), ("read-exec", ACCESS_READ | ACCESS_EXEC), @@ -293,8 +280,7 @@ pub const FS_DIRECTIVES: &[(&str, u64)] = &[ ("read-write-exec", ACCESS_READ | ACCESS_WRITE | ACCESS_EXEC), ]; -/// Parse a zone's Landlock policy file. Paths must be absolute and free of `..`: -/// they name places in the zone's pivoted root. +/// Parse a zone's Landlock policy file; paths are absolute, free of `..`, as the zone sees them. pub fn parse_policy(text: &str, source: &str) -> Result, String> { let mut out: Vec = Vec::new(); for (i, raw) in text.lines().enumerate() { @@ -336,8 +322,7 @@ pub fn parse_policy(text: &str, source: &str) -> Result, String> { Ok(out) } -/// Apply a zone's policy file as a layer over the base rules. Runs after -/// `confine_pivoted_zone`, inside the zone; a missing path is an error. +/// Apply a zone's policy file as a layer over `confine_pivoted_zone`'s rules. pub fn confine_further(rules: &[ZoneRule]) -> Result<(), LandlockError> { let mut rs = Ruleset::new()?; for r in rules { @@ -346,14 +331,12 @@ pub fn confine_further(rules: &[ZoneRule]) -> Result<(), LandlockError> { rs.restrict_self() } -/// For `kryptikd confine-test`: confine this process, in the caller's mount -/// namespace, to `rootfs` (read, write, exec) and `extra_ro`. +/// For `kryptikd confine-test`: confine this process to `rootfs` and, read-only, `extra_ro`. pub fn confine_to_zone(rootfs: &str, extra_ro: &[&str]) -> Result<(), LandlockError> { let mut rs = Ruleset::new()?; rs.allow(rootfs, ACCESS_READ | ACCESS_WRITE | ACCESS_EXEC)?; for p in extra_ro { - /* Optional: one that is missing, or a link as /lib is on a merged-usr - * host (the /usr rule covers it), is skipped. The zone rootfs is not. */ + // Optional: a missing one, or a link like merged-usr's /lib (under /usr), is skipped. let _ = rs.allow(p, ACCESS_READ | ACCESS_EXEC); } rs.restrict_self() @@ -367,9 +350,8 @@ pub struct ZoneRule { pub required: bool, } -/// Rules for a zone already pivoted into its root (`rootfs::pivot_into`). -/// "/" gets read and exec only, so what a zone must not modify is denied write -/// by Landlock as well as by its mount flags. Writable places are named. +/// Rules for a pivoted zone: "/" gets read and exec only, so what a zone must not modify is +/// denied by Landlock as well as by its mount flags. pub fn zone_rules(home: &str) -> Vec { let rule = |path: &str, access: u64, required: bool| ZoneRule { path: path.to_string(), @@ -390,8 +372,7 @@ pub fn zone_rules(home: &str) -> Vec { ] } -/// The nic zone's extra writable places: the private tmpfs mounts on /run and -/// /var/lib that `rootfs::pivot_into` gives it for network state. No exec. +/// The nic zone's private /run and /var/lib tmpfs mounts, for network state; no exec. pub fn nic_zone_rules() -> Vec { ["/run", "/var/lib"] .iter() @@ -423,8 +404,7 @@ pub fn describe_access(access: u64) -> String { parts.join("+") } -/// Apply `zone_rules` to the current process. Must run after pivot_root and -/// before the seccomp filter (landlock_* are not in the allowlist). +/// Apply `zone_rules` after pivot_root and before seccomp, whose allowlist lacks landlock_*. pub fn confine_pivoted_zone(home: &str, nic: bool) -> Result<(), LandlockError> { let mut rs = Ruleset::new()?; let mut rules = zone_rules(home); diff --git a/compartments/kryptikd/src/landlock/tests.rs b/compartments/kryptikd/src/landlock/tests.rs index e7441e63..645a3d21 100644 --- a/compartments/kryptikd/src/landlock/tests.rs +++ b/compartments/kryptikd/src/landlock/tests.rs @@ -1,7 +1,7 @@ use super::*; #[test] -fn parse_policy_accepts_and_refuses() { +fn policy_parsing() { let p = parse_policy("# c\nread-exec /\nread-write /tmp\n\nread /usr/share # trailing\n", "t").unwrap(); assert_eq!(p.len(), 3); assert_eq!(p[0].path, "/"); @@ -93,19 +93,19 @@ fn second_layer_narrows_never_widens() { } #[test] -fn packed_attr_is_twelve_bytes() { +fn packed_attr_size() { // Same layout as the kernel's packed struct. assert_eq!(std::mem::size_of::(), 12); } #[test] -fn ruleset_attrs_have_expected_sizes() { +fn ruleset_attr_sizes() { assert_eq!(std::mem::size_of::(), 8); assert_eq!(std::mem::size_of::(), 16); } #[test] -fn access_mask_grows_with_abi() { +fn access_mask_by_abi() { let v3 = access_mask_for(MIN_ABI); let v5 = access_mask_for(5); assert_eq!(v3 & (FS_REFER | FS_TRUNCATE), FS_REFER | FS_TRUNCATE, "REFER and TRUNCATE at the minimum ABI"); @@ -114,13 +114,13 @@ fn access_mask_grows_with_abi() { } #[test] -fn read_access_includes_dirs_and_files() { +fn read_access_bits() { assert_ne!(ACCESS_READ & FS_READ_FILE, 0); assert_ne!(ACCESS_READ & FS_READ_DIR, 0); } #[test] -fn write_access_includes_truncate_and_refer() { +fn write_access_bits() { assert_ne!(ACCESS_WRITE & FS_TRUNCATE, 0); assert_ne!(ACCESS_WRITE & FS_REFER, 0); assert_ne!(ACCESS_WRITE_FILE & FS_TRUNCATE, 0); @@ -128,7 +128,7 @@ fn write_access_includes_truncate_and_refer() { } #[test] -fn device_node_creation_is_never_granted() { +fn no_device_node_creation() { // MAKE_CHAR and MAKE_BLOCK are handled, so denied, and no rule grants them. for r in zone_rules("/home/t") { assert_eq!(r.access & (FS_MAKE_CHAR | FS_MAKE_BLOCK), 0, "{}", r.path); @@ -137,7 +137,7 @@ fn device_node_creation_is_never_granted() { } #[test] -fn root_rule_never_grants_write() { +fn root_never_writable() { // Rules are additive: write on "/" would be write on every mount under it. let rules = zone_rules("/home/t"); let root = rules.iter().find(|r| r.path == "/").expect("no rule for /"); @@ -163,7 +163,7 @@ fn root_rule_never_grants_write() { } #[test] -fn nic_zone_adds_state_dirs_without_exec() { +fn nic_zone_state_dirs() { let extra = nic_zone_rules(); let paths: Vec<&str> = extra.iter().map(|r| r.path.as_str()).collect(); assert_eq!(paths, vec!["/run", "/var/lib"]); @@ -177,7 +177,7 @@ fn nic_zone_adds_state_dirs_without_exec() { } #[test] -fn ruleset_can_be_created_when_supported() { +fn ruleset_creation() { match abi_version() { Some(v) if v >= MIN_ABI => { let rs = Ruleset::new().expect("ruleset creation should succeed"); diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index 8d2c08e3..ae9f34c4 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -1,9 +1,6 @@ -//! Minimal rtnetlink client for the zone topology (docs/design/net-zone.md). -//! -//! Built here because kryptikd depends on `libc` alone (ADR-010) and busybox -//! `ip` cannot create a veth peer in another namespace or set bridge port -//! flags. A netlink socket belongs to the namespace it was opened in, so each -//! request opens its own, and none succeeds until the kernel acks it. +//! Minimal netlink client for the zone topology (docs/design/net-zone.md): kryptikd uses only +//! `libc` (ADR-010), and busybox `ip` cannot put a veth peer in another namespace or set bridge +//! port flags. A socket belongs to the namespace it was opened in, so each request opens its own. use std::ffi::CString; use std::io; @@ -38,8 +35,7 @@ const NLA_F_NESTED: u16 = 0x8000; const IFLA_IFNAME: u16 = 3; const IFLA_MASTER: u16 = 10; -/* A bridge setlink acks an unknown attribute and ignores it, so a wrong - * number here fails silently: the isolation test checks sysfs and the wire. */ +// A bridge acks and ignores an unknown attribute, so the isolation test checks sysfs and the wire. const IFLA_PROTINFO: u16 = 12; const IFLA_LINKINFO: u16 = 18; const IFLA_NET_NS_FD: u16 = 28; @@ -154,7 +150,7 @@ impl Msg { } } -/// Most reply payload one request may gather; real replies are a few hundred bytes. +/// Cap on the reply payload one request gathers; replies are a few hundred bytes. const MAX_REPLY: usize = 64 * 1024; /// One request/ack exchange on a fresh NETLINK_ROUTE socket. @@ -162,16 +158,14 @@ fn transact(msg: Vec, what: &str) -> io::Result<()> { transact_on(NETLINK_ROUTE, msg, what).map(|_| ()) } -/// One request on a fresh socket of protocol `proto`, read until the ack or -/// DONE. Returns the payloads of the replies before it, without their headers. +/// One request on a fresh `proto` socket; returns the reply payloads that precede the ack or DONE. fn transact_on(proto: libc::c_int, msg: Vec, what: &str) -> io::Result> { let fd = unsafe { libc::socket(libc::AF_NETLINK, libc::SOCK_RAW | libc::SOCK_CLOEXEC, proto) }; if fd < 0 { return Err(io::Error::last_os_error()); } let result = (|| { - /* Connect to port 0 so only the kernel can reply. Unconnected, anything - * with CAP_NET_ADMIN in this namespace could, and the nic zone has it. */ + // Connect to port 0 so only the kernel can reply, not the nic zone with its CAP_NET_ADMIN. let mut kernel: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; kernel.nl_family = libc::AF_NETLINK as libc::sa_family_t; let rc = unsafe { @@ -252,8 +246,7 @@ fn check_name(name: &str) -> io::Result<()> { Ok(()) } -/// Create a veth pair `a` <-> `b`. When `peer_ns` is given, `b` is created in -/// that network namespace and never appears in this one. +/// Create a veth pair `a` <-> `b`, with `b` born in the namespace `peer_ns` if given. pub fn create_veth(a: &str, b: &str, peer_ns: Option) -> io::Result<()> { check_name(a)?; check_name(b)?; @@ -296,8 +289,7 @@ pub fn set_master(dev: &str, master: &str) -> io::Result<()> { transact(m.finish(), &format!("enslave {dev} to {master}")) } -/// Set a bridge port's isolation flag. Isolated ports never exchange frames -/// with each other, and a zone cannot clear the flag from its end of the veth. +/// Isolated bridge ports never exchange frames, and a zone cannot clear the flag from its end. pub fn set_port_isolated(dev: &str, on: bool) -> io::Result<()> { let idx = index_of(dev)?; let mut m = Msg::new(RTM_SETLINK, 0, 1); @@ -323,8 +315,7 @@ pub fn set_up(dev: &str) -> io::Result<()> { transact(m.finish(), &format!("bring up {dev}")) } -/// Move `dev` into the namespace behind `ns_fd`. A wireless netdev is -/// namespace-local and gets EINVAL; move its wiphy with `set_wiphy_netns`. +/// Move `dev` into `ns_fd`'s namespace; a wireless netdev refuses (EINVAL): see `set_wiphy_netns`. pub fn set_netns(dev: &str, ns_fd: RawFd) -> io::Result<()> { let idx = index_of(dev)?; let mut m = Msg::new(RTM_NEWLINK, 0, 1); @@ -372,8 +363,7 @@ fn genl_family_id(name: &str) -> io::Result { )) } -/// Move a wiphy and its interfaces, names intact, into the namespace behind -/// `ns_fd` (`iw phy set netns`). Needs CAP_NET_ADMIN where it is now. +/// `iw phy set netns`: move a wiphy and its interfaces into `ns_fd`'s netns (CAP_NET_ADMIN). pub fn set_wiphy_netns(phy: u32, ns_fd: RawFd) -> io::Result<()> { let family = genl_family_id("nl80211")?; let mut m = Msg::new(family, 0, 1); @@ -454,8 +444,7 @@ pub fn open_netns_of(pid: libc::pid_t) -> io::Result { Ok(fd) } -/// Run `f` inside the network namespace behind `ns_fd`, then switch back. -/// Needs CAP_SYS_ADMIN over both namespaces; setns moves only this thread. +/// Run `f` in `ns_fd`'s netns and switch back (CAP_SYS_ADMIN over both); moves only this thread. pub fn with_netns(ns_fd: RawFd, f: impl FnOnce() -> io::Result) -> io::Result { let mine = open_netns_of(unsafe { libc::getpid() })?; if unsafe { libc::setns(ns_fd, libc::CLONE_NEWNET) } < 0 { @@ -474,8 +463,7 @@ pub fn with_netns(ns_fd: RawFd, f: impl FnOnce() -> io::Result) -> io::Res result } -/// Routed-zone address plan (docs/design/net-zone.md): the bridge is -/// 10.19.0.1/24 and fd19::1/64; host number `k` is 10.19.0.k and fd19::k. +/// The bridge is 10.19.0.1/24 and fd19::1/64; zone host `k` is 10.19.0.k and fd19::k. pub const BRIDGE_V4: [u8; 4] = [10, 19, 0, 1]; pub const BRIDGE_V6: [u8; 16] = [0xfd, 0x19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; diff --git a/compartments/kryptikd/src/netlink/tests.rs b/compartments/kryptikd/src/netlink/tests.rs index d9275315..32e31f0c 100644 --- a/compartments/kryptikd/src/netlink/tests.rs +++ b/compartments/kryptikd/src/netlink/tests.rs @@ -1,8 +1,7 @@ use super::*; use std::fs; -/// Run `body` in a forked child in a fresh user and network namespace. -/// Returns its exit code, or 77 when no user namespace could be created. +/// Run `body` in a forked child in fresh user and network namespaces; 77 if they cannot be made. pub(crate) fn in_userns_netns(body: impl FnOnce() -> i32) -> i32 { let pid = unsafe { libc::fork() }; assert!(pid >= 0); @@ -68,9 +67,7 @@ fn hwsim_netdev() -> Option { found.into_iter().next() } -/// Root and mac80211_hwsim only. The netdev alone gets EINVAL; the wiphy -/// move carries it, same name, into the holder, and cfg80211 returns it -/// to the initial namespace when the holder dies. +/// Root and mac80211_hwsim only; the wiphy returns to the initial namespace when its holder dies. #[test] fn wireless_moves_by_wiphy() { use std::process::Command; @@ -100,7 +97,6 @@ fn wireless_moves_by_wiphy() { panic!("{dev}: no wiphy index ({other:?})"); } }; - // The netdev alone must refuse: that refusal is why the wiphy path exists. let (holder, zone_ns) = match spawn_netns_holder() { Ok(v) => v, Err(c) => { @@ -109,6 +105,7 @@ fn wireless_moves_by_wiphy() { } }; let r: Result<(), String> = (|| { + // The netdev alone must refuse: that refusal is why the wiphy path exists. match set_netns(&dev, zone_ns) { Err(e) if e.raw_os_error() == Some(libc::EINVAL) => {} Err(e) => return Err(format!("RTM_SETLINK on {dev}: expected EINVAL, got {e}")), @@ -148,8 +145,7 @@ fn wireless_moves_by_wiphy() { #[test] fn message_layout_matches_kernel() { - /* ifinfomsg is 16 bytes, attributes are 4-aligned, a nested length - * covers its payload, and the header length is the total. */ + // ifinfomsg is 16 bytes, attributes 4-aligned, and a nest's length covers its payload. let mut m = Msg::new(RTM_NEWLINK, NLM_F_CREATE, 7); m.ifinfomsg(0, 0, 0, 0); m.attr_str(IFLA_IFNAME, "ab"); // 4 + 3 = 7 -> padded to 8 @@ -180,8 +176,7 @@ fn address_plan_is_fixed() { assert!(check_name("a/b").is_err()); } -/// Fork a child that unshares a network namespace and waits to be killed. -/// Returns (pid, netns fd); the caller's user namespace owns the namespace. +/// Fork a child that holds a new netns, owned by the caller's user namespace, until killed. pub(crate) fn spawn_netns_holder() -> Result<(libc::pid_t, RawFd), i32> { let mut p = [0 as RawFd; 2]; if unsafe { libc::pipe(p.as_mut_ptr()) } < 0 { @@ -267,8 +262,7 @@ fn udp_received(fd: RawFd) -> bool { unsafe { libc::recv(fd, b.as_mut_ptr() as *mut libc::c_void, b.len(), 0) > 0 } } -/// Veth, addresses, bridge, port and routes in a private namespace, plus -/// two refusals (duplicate address and name) that show requests are acked. +/// Veth, addresses, bridge, port and routes; refused duplicates show that requests are acked. #[test] fn veth_bridge_addresses_routes() { let rc = in_userns_netns(|| { @@ -349,7 +343,6 @@ fn isolated_ports_block_zone_to_zone() { let r: Result<(), i32> = (|| { let (gc1, ns1) = spawn_netns_holder()?; let (gc2, ns2) = spawn_netns_holder()?; - let _kill = (gc1, gc2); step(40, create_bridge("kryptik0"))?; step(41, set_up("kryptik0"))?; step(42, add_addr4("kryptik0", [10, 99, 0, 254], 24))?; @@ -389,8 +382,7 @@ fn isolated_ports_block_zone_to_zone() { } step(52, set_port_isolated("kv-z1", false))?; step(53, set_port_isolated("kv-z2", false))?; - /* The unanswered ARP request left the neighbour entry in - * retransmit backoff (about 1 s); keep sending for up to 3 s. */ + // The unanswered ARP left the neighbour in backoff (about 1 s): retry for up to 3 s. let mut delivered = false; for _ in 0..10 { udp_send(tx1, [10, 99, 0, 2], 9999); diff --git a/compartments/kryptikd/src/netzone.rs b/compartments/kryptikd/src/netzone.rs index 5a1536dc..c8d06d4c 100755 --- a/compartments/kryptikd/src/netzone.rs +++ b/compartments/kryptikd/src/netzone.rs @@ -1,20 +1,17 @@ -//! Zone network topology (docs/design/net-zone.md): who owns the NIC, and how -//! a routed zone reaches it. +//! Zone network topology (docs/design/net-zone.md): who owns the NIC and how routed zones reach it. //! //! ```text //! physical NIC --moved into--> net zone's netns (mode = "nic") //! kryptik0 bridge 10.19.0.1/24, fd19::1/64 -//! routed zone k: kv- in net's netns, on kryptik0, ISOLATED +//! routed zone k: kv- in net's netns, on kryptik0, isolated //! eth0 in the zone's netns: 10.19.0.k/24, fd19::k/64, //! default routes via the bridge //! zone 0: keeps lo only once net has taken the NIC //! mode = "none": nothing is ever created //! ``` //! -//! Runs in the root parent while the new zone waits at its handshake. It needs -//! CAP_SYS_ADMIN over the initial namespace, so an unprivileged launch gets -//! loopback only. NAT, the forward policy and the resolver belong to the nic -//! zone's own program, tools/net/netzone-init.sh. +//! Runs in the root parent while the zone waits at its handshake; an unprivileged launch gets +//! loopback only. NAT, forwarding policy and the resolver belong to tools/net/netzone-init.sh. use std::ffi::CStr; use std::io; @@ -27,8 +24,7 @@ use crate::zone::{NetworkMode, Zone}; pub const BRIDGE: &str = "kryptik0"; -/// Where fallback tunnel devices (sit0, ...; our kernel builds SIT in) appear: -/// 0 = every new namespace, 1 = the initial one only, 2 = none. +/// Where fallback tunnel devices appear: 0 = every new namespace, 1 = the initial one, 2 = none. pub const FB_TUNNELS_SYSCTL: &str = "/proc/sys/net/core/fb_tunnels_only_for_init_net"; /// Fallback devices the tunnel modules create per namespace. @@ -57,8 +53,7 @@ pub fn devices_besides_lo() -> io::Result> { Ok(out) } -/// The fallback devices a new network namespace would start with here, or -/// None if it would hold loopback only. +/// The fallback devices a new network namespace would start with here, or None for loopback only. pub fn fallback_tunnels_expected() -> Option> { let v = std::fs::read_to_string(FB_TUNNELS_SYSCTL).ok()?; if v.trim() != "0" { @@ -73,19 +68,16 @@ pub fn fallback_tunnels_expected() -> Option> { } } -/// Make sure new network namespaces start empty: Ok(Some(note)) if the sysctl -/// had to be raised to 1, Err if it could not be and a zone would get devices. +/// Make new network namespaces start empty; Ok(Some(note)) if the sysctl had to be raised to 1. pub fn suppress_fallback_tunnels() -> Result, String> { let Some(devs) = fallback_tunnels_expected() else { return Ok(None) }; match std::fs::write(FB_TUNNELS_SYSCTL, "1") { Ok(()) => Ok(Some(format!( - "set {FB_TUNNELS_SYSCTL} = 1 (was 0): this kernel would otherwise create {} in every \ - new network namespace, a zone's included", + "set {FB_TUNNELS_SYSCTL} = 1 (was 0) so new network namespaces do not get {}", devs.join(", ") ))), Err(e) => Err(format!( - "this kernel creates {} in every new network namespace ({FB_TUNNELS_SYSCTL} = 0) and \ - this process cannot change that ({e}); a zone would not start loopback-only", + "cannot set {FB_TUNNELS_SYSCTL} = 1 ({e}), so the zone's network namespace would get {}", devs.join(", ") )), } @@ -116,8 +108,7 @@ fn io(what: &str, e: io::Error) -> NetError { NetError::Io(format!("{what}: {e}")) } -/// A routed zone's host number on the bridge subnet, from its declared uid -/// base: 131072 -> 2, 196608 -> 3, ... Bases are unique, so numbers are too. +/// A routed zone's host number, from its unique uid base: 131072 -> 2, 196608 -> 3, ... pub fn host_number(zone: &Zone) -> Option { let base = zone.uid_base?; let k = (base - crate::zone::IDENTITY_MIN) / crate::zone::IDENTITY_STRIDE + 2; @@ -136,8 +127,7 @@ fn up_lo() -> io::Result<()> { netlink::set_up("lo") } -/// An uplink's IPv4 configuration, read back so it can be re-applied after a -/// namespace move, which flushes addresses and routes. +/// An uplink's IPv4 configuration, kept to re-apply after a namespace move flushes it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Uplink { pub addrs: Vec<([u8; 4], u8)>, @@ -201,8 +191,7 @@ pub fn uplink_config(nic: &str) -> io::Result { Ok(Uplink { addrs, gateway }) } -/// Move `nic` into `zone_ns` and re-apply the IPv4 configuration the move -/// flushes (no DHCP here; a client in the nic zone can take over). Returns it. +/// Move `nic` into `zone_ns`, re-applying the IPv4 configuration it loses (DHCP is the zone's job). fn carry_nic(nic: &str, zone_ns: i32) -> Result { let cfg = uplink_config(nic).map_err(|e| io(&format!("read the configuration of {nic}"), e))?; // A wireless netdev is namespace-local; move its wiphy instead. @@ -217,8 +206,7 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { } netlink::set_up(nic)?; if let Some(gw) = cfg.gateway { - /* With a second uplink the first default route stands (the kernel - * refuses a duplicate); the zone's DHCP client sorts them later. */ + // The first uplink's default route stands; the zone's DHCP client sorts them out later. match netlink::add_default_route4(gw, nic) { Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { eprintln!("kryptikd: {nic}: the nic zone already has a default route; keeping the first") @@ -232,8 +220,7 @@ fn carry_nic(nic: &str, zone_ns: i32) -> Result { Ok(cfg) } -/// Interfaces here that sit on a bus device (`/sys/class/net//device` -/// exists); software devices never do. `[network] nic = "*"` moves these. +/// Interfaces on a bus device (`/sys/class/net//device`): what `[network] nic = "*"` moves. pub fn physical_interfaces() -> io::Result> { physical_interfaces_under(Path::new("/sys/class/net")) } @@ -268,9 +255,8 @@ pub fn plumb_nic_zone(zone: &Zone, zone_ns: i32, zones_dir: &Path) -> Result<(), Ok(()) } -/// Attach every running routed zone to the nic zone in `nic_ns`; one result -/// per zone tried. A zone keeps the resolv.conf it started with, so one that -/// started before any gateway has no resolver until it restarts. +/// Attach every running routed zone to the nic zone in `nic_ns`, one result per zone. A zone keeps +/// its resolv.conf, so one started before any gateway has no resolver until it restarts. pub fn replumb_routed_zones(zones_dir: &Path, nic_ns: i32) -> Vec<(String, Result<(), NetError>)> { let mut out = Vec::new(); for name in registry::names() { @@ -298,8 +284,7 @@ pub fn replumb_routed_zones(zones_dir: &Path, nic_ns: i32) -> Vec<(String, Resul /// Move the uplinks into the nic zone and create kryptik0 there. fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { - /* Without `[network] nic` the zone gets the bridge and no uplink: kryptikd - * never picks a NIC to take out of zone 0 on its own. */ + // Without `[network] nic`, bridge only: kryptikd never picks a NIC to take from zone 0 itself. let nics: Vec = match zone.nic.as_deref() { None => Vec::new(), // Every physical interface, wired or wireless; finding none is not an error. @@ -338,13 +323,8 @@ fn plumb_nic_zone_bridge(zone: &Zone, zone_ns: i32) -> Result<(), NetError> { if let Err(e) = netlink::add_addr6(BRIDGE, netlink::BRIDGE_V6, 64) { eprintln!("kryptikd: zone {:?}: IPv6 on {BRIDGE} not configured ({e}); IPv4 is", zone.name); } - /* Forwarding is set to 0 here, never inherited. tools/net/netzone-init.sh - * turns it on once its nftables policy is loaded; before that, the - * uplink could reach the routed zones this handshake reattaches. - * Forwarding also joins routed zones via the bridge address, past port - * isolation, but only with a host route, and so CAP_NET_ADMIN, which - * no routed zone keeps (policy::check_for_zone). - */ + /* Forwarding stays off until netzone-init.sh loads its nftables policy, or the uplink could + * reach routed zones; forwarding past port isolation also needs CAP_NET_ADMIN, which none keeps. */ sysctl("/proc/sys/net/ipv4/ip_forward", "0")?; if Path::new("/proc/sys/net/ipv6").exists() { sysctl("/proc/sys/net/ipv6/conf/all/forwarding", "0")?; @@ -371,9 +351,7 @@ pub fn plumb_routed_zone(zone: &Zone, zone_ns: i32, zones_dir: &Path, host_gid: attach_routed(&zone.name, k, net_ns.as_raw_fd(), zone_ns, Some(host_gid)) } -/// Create the veth from inside the nic zone with its peer born in the routed -/// zone as eth0, isolate the bridge port, then address and route eth0. On -/// failure the port is deleted, which takes the pair. +/// An isolated bridge port, its veth peer born in the zone as eth0, then addresses and routes. fn attach_routed(name: &str, k: u8, nic_ns: i32, zone_ns: i32, host_gid: Option) -> Result<(), NetError> { let port = port_name(name); let r = attach_v4(&port, k, nic_ns, zone_ns, host_gid); @@ -407,9 +385,8 @@ fn attach_v4(port: &str, k: u8, nic_ns: i32, zone_ns: i32, host_gid: Option netlink::add_addr4("eth0", netlink::zone_v4(k), 24)?; netlink::set_up("eth0")?; netlink::add_default_route4(netlink::BRIDGE_V4, "eth0")?; - /* Unprivileged ICMP echo for the zone's group, since a routed zone - * cannot keep CAP_NET_RAW. The range takes host gids: the parent - * writes it from outside the zone's user namespace. */ + /* Unprivileged ping for the zone's group, as no routed zone keeps CAP_NET_RAW; host gids, + * since the parent writes the range from outside the zone's user namespace. */ if let Some(g) = host_gid { let _ = sysctl("/proc/sys/net/ipv4/ping_group_range", &format!("{g} {g}")); } @@ -418,15 +395,12 @@ fn attach_v4(port: &str, k: u8, nic_ns: i32, zone_ns: i32, host_gid: Option .map_err(|e| io("address the zone's eth0", e)) } -/// A zone's network namespace, opened before its pid is trusted: callers check -/// `still_alive` after, so a reused pid cannot hand over another namespace. +/// A zone's netns, opened before callers check `still_alive`, so a reused pid cannot swap it. fn open_ns(st: ®istry::PidStamp) -> io::Result { netlink::open_netns_of(st.pid).map(|fd| unsafe { OwnedFd::from_raw_fd(fd) }) } -/// The running nic zone's network namespace. The root-owned zone files say -/// which zone that is, never what a namespace holds: a zone could create its -/// own kryptik0. +/// The running nic zone's netns, picked by the root-owned zone files: any zone can make a kryptik0. fn running_nic_zone_ns(zones_dir: &Path) -> Result { for name in registry::names() { let file = zones_dir.join(format!("{name}.toml")); @@ -478,8 +452,7 @@ fn plan_line(zone: &Zone, privileged: bool) -> String { (NetworkMode::Routed, true) => match host_number(zone) { Some(k) => format!( "network routed: eth0 = 10.19.0.{k}/24 fd19::{k:x}/64 via the nic zone's {BRIDGE}, \ - isolated port {}; forwarding and NAT are the nic zone's program's to enable \ - once its firewall is loaded (kryptikd leaves forwarding off)", + isolated port {}; forwarding and NAT wait for the nic zone's firewall", port_name(&zone.name) ), None => "network routed: needs [identity] uid_base to derive an address".into(), diff --git a/compartments/kryptikd/src/netzone/tests.rs b/compartments/kryptikd/src/netzone/tests.rs index 4e6feb35..725e99b6 100644 --- a/compartments/kryptikd/src/netzone/tests.rs +++ b/compartments/kryptikd/src/netzone/tests.rs @@ -10,8 +10,7 @@ fn z(mode: &str, base: Option, nic: Option<&str>) -> Zone { .unwrap() } -/// This namespace plays a nic zone that inherited forwarding on; after the -/// bridge half both forwarding knobs must read 0. +/// A nic zone that inherited forwarding on must read 0 in both knobs after the bridge half. #[test] fn bridge_half_turns_forwarding_off() { use crate::netlink::tests::in_userns_netns; @@ -52,14 +51,12 @@ fn bridge_half_turns_forwarding_off() { } } -/// A veth pair and a bridge must not count. Sysfs shows the network -/// namespace of whoever mounted it, so the child mounts its own. +/// Veths and bridges do not count; sysfs shows its mounter's netns, so the child mounts its own. #[test] -fn only_bus_devices_count_as_physical() { +fn software_devices_not_physical() { use crate::netlink::tests::{in_userns_netns, step}; use std::ffi::CString; - /* Before the fork: temp_dir() takes std's environment lock, and a child - * forked while another thread holds it waits forever. */ + // Before the fork: temp_dir() takes std's env lock, which a forked child could wait on forever. let dir = std::env::temp_dir().join(format!("kryptik-sysfs-{}", std::process::id())); let rc = in_userns_netns(|| { if std::fs::create_dir_all(&dir).is_err() { @@ -170,8 +167,7 @@ fn fresh_namespace_matches_prediction() { } } -/// This namespace plays the nic zone and a holder a zone with IPv6 -/// disabled; eth0 must still come out addressed, up and routed. +/// A routed zone with IPv6 disabled must still get eth0 addressed, up and routed. #[test] fn ipv4_survives_disabled_ipv6() { use crate::netlink::tests::{in_userns_netns, spawn_netns_holder, step}; @@ -219,7 +215,7 @@ fn ipv4_survives_disabled_ipv6() { eprintln!("zone routes:\n{routes}"); return Err(8); } - // Premise: IPv6 really was refused. + // Premise: IPv6 was refused. if v6.lines().any(|l| l.contains("eth0")) { eprintln!("IPv6 was not disabled in the zone namespace; this proved nothing:\n{v6}"); return Err(9); @@ -243,8 +239,7 @@ fn ipv4_survives_disabled_ipv6() { } } -/// A veth end with an address and default route plays the uplink; after -/// `carry_nic` it must be up with the same configuration in the holder. +/// A veth end plays the uplink; `carry_nic` must leave it up in the holder with its configuration. #[test] fn uplink_config_travels_with_nic() { use crate::netlink::tests::{in_userns_netns, spawn_netns_holder, step}; diff --git a/compartments/kryptikd/src/policy.rs b/compartments/kryptikd/src/policy.rs index b2793bf3..14025146 100755 --- a/compartments/kryptikd/src/policy.rs +++ b/compartments/kryptikd/src/policy.rs @@ -1,10 +1,7 @@ -//! Per-zone policy files (docs/design/zone-policy-files.md). -//! -//! One directive per line, each adding one thing to the base policy: -//! `allow-syscall NAME`, `allow-socket AF_X`, `allow-netlink NETLINK_X` or -//! `keep-capability CAP_X`. A file cannot re-allow a syscall the base policy -//! denies, capabilities come only from `caps::KEEPABLE`, and an unknown name -//! is an error with a line number. Any error refuses the launch. +//! Per-zone policy files (docs/design/zone-policy-files.md). Each line adds one thing to the +//! base policy: `allow-syscall NAME`, `allow-socket AF_X`, `allow-netlink NETLINK_X` or +//! `keep-capability CAP_X`. A denied syscall stays denied and capabilities come only from +//! `caps::KEEPABLE`; an unknown name is an error with its line, and any error refuses the launch. use std::collections::HashSet; use std::fmt; @@ -161,9 +158,8 @@ pub fn parse(text: &str, source: &str) -> Result { } impl Policy { - /// Only the nic zone may keep `CAP_NET_ADMIN` or `CAP_NET_RAW`. In any other - /// zone they would let it re-address its veth, route around port isolation - /// through the bridge address, or forge frames. + /// Only the nic zone may keep `CAP_NET_ADMIN` or `CAP_NET_RAW`; elsewhere they let a zone + /// re-address its veth, route around port isolation via the bridge address, or forge frames. pub fn check_for_zone(&self, zone: &crate::zone::Zone) -> Result<(), PolicyError> { if zone.network != crate::zone::NetworkMode::Nic { for (c, name) in self.keep_caps.iter().zip(&self.keep_cap_names) { diff --git a/compartments/kryptikd/src/policy/tests.rs b/compartments/kryptikd/src/policy/tests.rs index 9df3cc46..82064bd2 100644 --- a/compartments/kryptikd/src/policy/tests.rs +++ b/compartments/kryptikd/src/policy/tests.rs @@ -1,7 +1,7 @@ use super::*; #[test] -fn valid_file_adds_what_it_names() { +fn valid_file_adds_directives() { let p = parse( "# comment\n\ allow-syscall sched_setscheduler\n\ @@ -35,7 +35,7 @@ fn denied_syscall_cannot_be_allowed() { } #[test] -fn bad_lines_error_with_line_number() { +fn errors_carry_line_number() { for text in [ "allow-syscall nosuchcall\n", "allow-socket AF_NOPE\n", @@ -100,7 +100,7 @@ fn af_netlink_lifts_protocol_check() { } #[test] -fn relative_path_resolves_under_zones_dir() { +fn resolve_relative_and_absolute() { assert_eq!(resolve(Path::new("/etc/kryptik/zones"), "policy/net.seccomp"), PathBuf::from("/etc/kryptik/zones/policy/net.seccomp")); assert_eq!(resolve(Path::new("/etc/kryptik/zones"), "/abs/p.seccomp"), PathBuf::from("/abs/p.seccomp")); } diff --git a/compartments/kryptikd/src/registry.rs b/compartments/kryptikd/src/registry.rs index b512a2e8..032f580f 100644 --- a/compartments/kryptikd/src/registry.rs +++ b/compartments/kryptikd/src/registry.rs @@ -1,10 +1,6 @@ -//! The running-zone registry (docs/design/zone-registry.md): how another -//! kryptikd finds a running zone. There is no daemon; the launcher supervises. -//! -//! Liveness is a lock, not a pid: a launcher holds `flock(LOCK_EX)` on -//! `/lock` for its whole life, and the kernel releases it when the -//! launcher dies. The recorded pid carries its start time (field 22 of -//! `/proc//stat`) and is never signalled unless both match. +//! The running-zone registry (docs/design/zone-registry.md): how another kryptikd finds a +//! running zone. Liveness is the launcher's lifelong `flock(LOCK_EX)` on `/lock`, which +//! the kernel releases when it dies; a recorded pid counts only with its start time. use crate::cgroup; use std::fs; @@ -26,21 +22,18 @@ impl std::fmt::Display for RegistryError { match self { RegistryError::AlreadyRunning { zone, pid } if *pid > 0 => write!( f, - "zone {zone:?} is already running (launcher pid {pid}). \ - One instance per zone: stop it first, or use a different zone." + "zone {zone:?} is already running (launcher pid {pid}); stop it first" ), RegistryError::AlreadyRunning { zone, .. } => write!( f, - "zone {zone:?} is already being started by another kryptikd. \ - One instance per zone: wait for it, or stop it." + "zone {zone:?} is already being started by another kryptikd; wait for it, or stop it" ), RegistryError::Io { path, err } => write!(f, "{path}: {err}"), RegistryError::Malformed { path, what } => write!(f, "{path}: {what}"), RegistryError::UnsafeBase { path, why } => write!( f, - "refusing to use the zone registry at {path}: {why}. \ - Remove it (or point XDG_RUNTIME_DIR at a directory you own) \ - and start the zone again." + "refusing to use the zone registry at {path}: {why}; remove it, \ + or point XDG_RUNTIME_DIR at a directory you own" ), } } @@ -50,9 +43,7 @@ fn io_err(p: &Path, e: io::Error) -> RegistryError { RegistryError::Io { path: p.display().to_string(), err: e } } -/// Where the registry lives: `/run/kryptik/zones` for root, otherwise under the -/// user's runtime directory (`base_for`), so the same code and tests run -/// unprivileged on a developer host. +/// `/run/kryptik/zones` for root; per user otherwise (`base_for`), so tests run unprivileged. pub fn base() -> PathBuf { base_for( unsafe { libc::getuid() }, @@ -67,9 +58,8 @@ pub fn base_for(uid: u32, euid: u32, xdg_runtime_dir: Option<&str>) -> PathBuf { return PathBuf::from("/run/kryptik/zones"); } - /* XDG_RUNTIME_DIR only if it is an existing directory this user owns: a - * bogus one must not stop every launch, and one owned by someone else - * would let them read and forge entries. Otherwise a per-uid /tmp path. */ + /* XDG_RUNTIME_DIR only if it is a directory this user owns: a bogus one must not stop + * every launch, and someone else's would let them read and forge entries. */ if let Some(x) = xdg_runtime_dir { if !x.is_empty() { let p = Path::new(x); @@ -84,9 +74,8 @@ pub fn base_for(uid: u32, euid: u32, xdg_runtime_dir: Option<&str>) -> PathBuf { PathBuf::from(format!("/tmp/kryptik-{uid}/zones")) } -/// Create the registry base, or check that the one already there is ours. Any -/// local user can plant the /tmp fallback first, so a directory we would not -/// have created is refused, never adopted by chown or chmod. +/// Create the registry base or vet the one there: any local user can plant the /tmp +/// fallback first, so a directory we would not have created is refused, never adopted. fn ensure_base(b: &Path) -> Result<(), RegistryError> { // The parent too: owning `/tmp/kryptik-` is enough to replace `zones`. if let Some(parent) = b.parent() { @@ -101,8 +90,7 @@ fn ensure_base(b: &Path) -> Result<(), RegistryError> { fn check_or_create(p: &Path) -> Result<(), RegistryError> { use std::os::unix::fs::DirBuilderExt; - /* symlink_metadata, not metadata: a symlink to a directory we own would - * pass every check below and could still be re-aimed. */ + // symlink_metadata: a symlink to a directory we own would pass every check, then be re-aimed. match fs::symlink_metadata(p) { Err(e) if e.kind() == io::ErrorKind::NotFound => { let mut db = fs::DirBuilder::new(); @@ -123,8 +111,7 @@ fn check_or_create(p: &Path) -> Result<(), RegistryError> { } } -/// Refuse an existing path unless it is a real directory, ours, and writable by -/// no one else; tighten it to 0700 if it is only too readable. +/// Accept only a directory of ours that no one else can write, tightening it to 0700 if needed. fn check_existing(p: &Path, md: &fs::Metadata) -> Result<(), RegistryError> { use std::os::unix::fs::MetadataExt; let me = unsafe { libc::getuid() }; @@ -138,10 +125,7 @@ fn check_existing(p: &Path, md: &fs::Metadata) -> Result<(), RegistryError> { Some(format!("it is owned by uid {}, not by uid {me}", md.uid())) } else if mode & 0o022 != 0 { // Tightening it would not undo what was placed inside while it was open. - Some(format!( - "its mode is {mode:04o}: it is writable by others, so its contents \ - cannot be trusted even though it belongs to uid {me}" - )) + Some(format!("its mode is {mode:04o}: writable by others, so its contents cannot be trusted")) } else { None }; @@ -149,13 +133,10 @@ fn check_existing(p: &Path, md: &fs::Metadata) -> Result<(), RegistryError> { return Err(RegistryError::UnsafeBase { path: p.display().to_string(), why }); } - /* Only too readable (0755, say): it leaks what this user runs but is no - * foothold. Safe to repair, since the uid check has already excluded a - * directory someone else planted. */ + // Only too readable (0755, say): no foothold, and ours per the uid check, so repair it. if mode != 0o700 { eprintln!( - "kryptikd: tightening {} from {mode:04o} to 0700; the zone registry \ - lists what you are running and should not be readable by others", + "kryptikd: tightening {} from {mode:04o} to 0700: the zone registry shows what you run", p.display() ); set_mode(p, 0o700)?; @@ -175,10 +156,10 @@ pub struct PidStamp { pub start: u64, } -/// Field 22 of `/proc//stat`, the start time in clock ticks. Parsed after -/// the last `)`, since field 2 (the name) may contain spaces and parentheses. +/// Field 22 of `/proc//stat`, the start time in clock ticks. pub fn start_time(pid: i32) -> Option { let s = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // The name (field 2) may hold spaces and parentheses: start after the last `)`. let rest = &s[s.rfind(')')? + 1..]; // After the ')' come state (3), ppid (4), ...: field 22 is token 20. rest.split_whitespace().nth(19)?.parse().ok() @@ -220,8 +201,7 @@ impl PidStamp { pub enum State { /// No entry. Absent, - /// An entry whose lock is held: a launcher is alive. `launcher` is `None` - /// while it starts, since `claim()` locks before the fork that makes the pid. + /// The lock is held, so a launcher is alive (`launcher` is `None` while it starts). Running { launcher: Option, init: Option, @@ -232,18 +212,15 @@ pub enum State { Stale { launcher: Option, cgroup: Option }, } -/// Remove an entry and everything in it, found by listing the directory, so a -/// broker's stranded temporary file goes too. The caller holds the entry's lock. +/// Remove an entry and all it holds, stranded temporaries too; the caller holds its lock. fn sweep(dir: &Path) -> Result<(), RegistryError> { sweep_with(dir, &mut || {}) } -/// `sweep`, calling `between` after each removal: a test arrives there as a -/// claimer would. +/// `sweep`, calling `between` after each removal, where a test arrives as a claimer would. fn sweep_with(dir: &Path, between: &mut dyn FnMut()) -> Result<(), RegistryError> { - /* A privileged launch bind-mounts the Wayland proxy socket into the entry - * (spawn.rs, StagedSocket), and a dead launcher can leave the mount. Detach - * until the path is no mountpoint: each detach removes only the topmost. */ + /* A dead launcher can leave the Wayland socket's bind mount (spawn.rs, StagedSocket): + * detach until none is left, as each detach removes only the topmost. */ let wl = dir.join(crate::rootfs::WAYLAND_SOCKET_NAME); if let Ok(c) = std::ffi::CString::new(wl.display().to_string()) { while unsafe { libc::umount2(c.as_ptr(), libc::MNT_DETACH) } == 0 {} @@ -254,8 +231,7 @@ fn sweep_with(dir: &Path, between: &mut dyn FnMut()) -> Result<(), RegistryError Err(e) => return Err(io_err(dir, e)), }; for e in rd.flatten() { - /* The lock goes last: while it is there no claimer can take the name, - * so none can empty the directory and make it anew under these paths. */ + // The lock goes last: while it exists no claimer can take the name and remake the entry. if e.file_name() == "lock" { continue; } @@ -287,8 +263,7 @@ fn read_field(dir: &Path, name: &str) -> Option { }) } -/// Open the entry's lock file, creating it only for an owner (`claim`, -/// `reclaim`), never for a probe. `None` if there is no file or no directory. +/// Open the entry's lock file, created only for an owner, never a probe; `None` if absent. fn open_lock(dir: &Path, create: bool) -> Result, RegistryError> { let lock = dir.join("lock"); let c = std::ffi::CString::new(lock.as_os_str().as_encoded_bytes()) @@ -308,8 +283,7 @@ fn open_lock(dir: &Path, create: bool) -> Result, RegistryError> { Ok(Some(fd)) } -/// Whether `fd` is still the lock file `path` names. A reclaim unlinks the lock -/// it holds before releasing it, so a racing open can lock an orphaned inode. +/// Whether `fd` is still `path`'s inode: a racing open can lock one a reclaim has unlinked. fn same_inode(fd: RawFd, path: &Path) -> bool { use std::os::unix::fs::MetadataExt; let Ok(md) = fs::symlink_metadata(path) else { return false }; @@ -341,8 +315,7 @@ fn try_lock(dir: &Path) -> Result { Ok(Lock::Held(fd)) } -/// `try_lock`, waiting out a probe's instant on the shared lock (`held`). A -/// launcher holds its lock for its whole life, so Busy after 100 ms is one. +/// `try_lock`, waiting out a probe's instant on the shared lock; Busy after 100 ms is a launcher. fn lock_owner(dir: &Path) -> Result { let mut lock = try_lock(dir)?; for _ in 0..20 { @@ -355,9 +328,8 @@ fn lock_owner(dir: &Path) -> Result { Ok(lock) } -/// Whether a launcher holds the entry's lock: a shared non-blocking lock is -/// refused exactly when one does. A probe creates nothing and takes nothing -/// exclusive. No lock file (mid-claim or mid-reclaim) means not held. +/// Whether a launcher holds the entry's lock: a shared non-blocking flock fails only then. +/// The probe creates nothing; no lock file (mid-claim or mid-reclaim) means not held. fn held(dir: &Path) -> Result { let Some(fd) = open_lock(dir, false)? else { return Ok(false) }; let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) }; @@ -365,14 +337,12 @@ fn held(dir: &Path) -> Result { Ok(rc != 0) } -/// A zone's entry directory, for zone 0 acting on a running zone (the -/// clipboard move). The entry belongs to its launcher. +/// A zone's entry directory, for zone 0 acting on a running zone (the clipboard move). pub fn entry_dir(zone: &str) -> PathBuf { base().join(zone) } -/// Read a zone's state. The lock probe creates and holds nothing, so any -/// process may call this at any time. +/// Read a zone's state; any process may call this at any time. pub fn state(zone: &str) -> Result { let dir = base().join(zone); if !dir.exists() { @@ -394,16 +364,13 @@ pub fn state(zone: &str) -> Result { } } -/// Remove a stale entry, and kill whatever its cgroup still holds. The recorded -/// pid is never signalled, since it may have been reused; `cgroup.kill` reaches -/// only processes in the cgroup. +/// Remove a stale entry and kill what its cgroup holds, never the recorded (maybe reused) pid. pub fn reclaim(zone: &str) -> Result<(), RegistryError> { let dir = base().join(zone); if !dir.exists() { return Ok(()); } - /* Hold the lock for the sweep (docs/design/zone-registry.md): a launcher - * that claimed the name since the caller looked keeps its entry. */ + // Locked for the sweep: a launcher that claimed the name since the caller looked keeps it. let fd = match lock_owner(&dir)? { Lock::Held(fd) => fd, Lock::Busy => return Err(RegistryError::AlreadyRunning { zone: zone.to_string(), pid: -1 }), @@ -417,8 +384,7 @@ pub fn reclaim(zone: &str) -> Result<(), RegistryError> { fn reclaim_locked(dir: &Path, zone: &str) -> Result<(), RegistryError> { if let Some(path) = read_field(dir, "cgroup") { let p = Path::new(&path); - /* Only inside kryptikd's own cgroup tree: a malformed or planted entry - * must not aim cgroup.kill at, say, /sys/fs/cgroup/user.slice. */ + // Only in kryptikd's own tree: a planted entry must not aim cgroup.kill at user.slice. if !p.starts_with(cgroup::kryptik_root()) { eprintln!( "kryptikd: ignoring a registry entry for zone {zone:?} that names a cgroup \ @@ -439,8 +405,7 @@ fn reclaim_locked(dir: &Path, zone: &str) -> Result<(), RegistryError> { sweep(dir) } -/// An entry this process owns. Dropping it removes the entry and releases the -/// lock, so an early return cannot leave a zone looking live. +/// An entry we own; the drop removes it, so an early return cannot leave a zone looking live. #[derive(Debug)] pub struct Handle { dir: PathBuf, @@ -452,8 +417,7 @@ impl Handle { &self.dir } - /// Write one entry field: O_NOFOLLOW, so a planted symlink cannot aim the - /// truncating write, and 0600 from creation. Backs up `ensure_base`. + /// Write one entry field, 0600 and O_NOFOLLOW: a planted symlink cannot aim the truncation. fn write(&self, name: &str, contents: &str) -> Result<(), RegistryError> { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; @@ -506,9 +470,8 @@ impl Drop for Handle { } } -/// Claim a zone name, reclaiming a stale entry if there is one. `mkdir` is the -/// atomic step; on EEXIST the lock tells running from stale, and a stale entry -/// is reclaimed once: EEXIST after that means another launcher won. +/// Claim a zone name: `mkdir` is the atomic step, and on EEXIST the lock tells running from +/// stale. A stale entry is reclaimed once; EEXIST after that means another launcher won. pub fn claim(zone: &str) -> Result { let b = base(); ensure_base(&b)?; @@ -519,8 +482,7 @@ pub fn claim(zone: &str) -> Result { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { match state(zone)? { - /* Another launcher is between its lock and its fork: give - * it 50 ms once, then refuse. */ + // Another launcher is between its lock and its fork: give it 50 ms once. State::Running { launcher: None, .. } if attempt == 0 => { std::thread::sleep(std::time::Duration::from_millis(50)); continue; diff --git a/compartments/kryptikd/src/registry/tests.rs b/compartments/kryptikd/src/registry/tests.rs index 71738746..d4d08293 100644 --- a/compartments/kryptikd/src/registry/tests.rs +++ b/compartments/kryptikd/src/registry/tests.rs @@ -11,7 +11,7 @@ fn pid_stamp_round_trips() { } #[test] -fn wrong_start_time_is_not_alive() { +fn wrong_start_time_not_alive() { // Same pid, different process. let me = unsafe { libc::getpid() }; let real = PidStamp::of(me).unwrap(); @@ -38,8 +38,7 @@ fn registry_base_is_per_user() { if unsafe { libc::geteuid() } == 0 { assert_eq!(b, Path::new("/run/kryptik/zones")); } else { - /* As text: Path::starts_with compares whole components, and - * "/tmp/kryptik-" is not one. */ + // As text: Path::starts_with compares whole components, and "/tmp/kryptik-" is not one. let s = b.to_string_lossy(); assert!( s.starts_with("/run/user/") || s.starts_with("/tmp/kryptik-"), @@ -69,7 +68,7 @@ fn bogus_runtime_dir_falls_back() { fn unsafe_registry_dir_is_refused() { // What an attacker could leave in /tmp before the user's first run. use std::os::unix::fs::MetadataExt; - let root = std::env::temp_dir().join(format!("kryptik-f1-{}", std::process::id())); + let root = std::env::temp_dir().join(format!("kryptik-regdir-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).unwrap(); let mode_of = |p: &Path| fs::metadata(p).unwrap().mode() & 0o777; @@ -126,8 +125,7 @@ fn second_claim_refused_until_drop() { } other => panic!("expected Running with our pid, got {other:?}"), } - /* flock is per open file description, so a second claim from this - * process is refused like another launcher's. */ + // flock is per open file description: a second claim from this process is refused too. match claim(&zone) { Err(RegistryError::AlreadyRunning { .. }) => {} other => panic!("a second claim must be refused, got {other:?}"), diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index 1e1d64b8..ef9f44fd 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -1,9 +1,6 @@ -//! Zone roots built with pivot_root, so host paths do not exist for a zone -//! rather than being denied: Landlock cannot revoke an inherited descriptor -//! or stop chmod on files the zone's uid owns. -//! -//! The root is a fresh tmpfs. System paths are bound read-only recursively, -//! /etc is synthesized, and the data directory is bound only at /home/. +//! Zone roots built with pivot_root, so host paths do not exist for a zone: Landlock cannot +//! revoke an inherited descriptor or stop chmod on files the zone's uid owns. The root is a +//! fresh tmpfs with system paths bound read-only, /etc synthesized and the data at /home/. use std::ffi::CString; use std::fs; @@ -78,8 +75,7 @@ fn mount_raw( Ok(()) } -/* mount_setattr(2), Linux 5.12+: the only way to make a whole bind tree - * read-only in one step. glibc has no wrapper. */ +// mount_setattr(2), Linux 5.12+ with no glibc wrapper: makes a whole bind tree read-only at once. const SYS_MOUNT_SETATTR: libc::c_long = 442; const AT_RECURSIVE: libc::c_uint = 0x8000; const MOUNT_ATTR_RDONLY: u64 = 0x1; @@ -154,51 +150,33 @@ fn bind_over_ro(src: &str, target: &str) -> Result<(), RootfsError> { set_mount_attr(target, MOUNT_ATTR_RDONLY | MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV, false) } -/// System directories a zone gets, read-only, nosuid, nodev. Not /etc: see -/// `populate_etc`. +/// System directories a zone gets, read-only, nosuid, nodev; /etc is `populate_etc`'s. pub const SYSTEM_PATHS: &[&str] = &["/usr", "/lib", "/lib64", "/bin", "/sbin"]; -/// Host files under /etc a zone may read: public, machine-independent data. -/// Never ld.so.preload (a zone gets its own, naming only ALLOCATOR), -/// machine-id (links zones to the host), the host's identity files, -/// resolv.conf, localtime (zones run UTC), or any secret. man refuses to -/// start without man_db.conf. ssl/cert.pem is OpenSSL's default CA file: the -/// ssl/certs directory holds the bundle but no hash links, so without -/// cert.pem no TLS peer verifies in a zone. +/// Host files under /etc a zone may read: public, machine-independent data. man needs +/// man_db.conf; OpenSSL needs cert.pem, as ssl/certs has the bundle but no hash links. pub const ETC_RO_FILES: &[&str] = &["/etc/ld.so.cache", "/etc/services", "/etc/protocols", "/etc/man_db.conf", "/etc/ssl/cert.pem"]; -/// The nic zone's own configuration, bound into that zone alone: its DHCP -/// client defaults, time sources (docs/design/time.md) and release source -/// (docs/design/update-channel.md). +/// The nic zone's own configuration (DHCP client, time sources, release source), for it alone. pub const NIC_ETC_FILES: &[&str] = &["/etc/dhcpcd.conf", "/etc/kryptik/time.conf", "/etc/kryptik/update.conf"]; -/// Files of a zone's /proc about the whole machine, hidden behind /dev/null: -/// the interrupt counts (interrupts, softirqs, stat's intr line, pressure/irq) -/// and the context-switch counts (stat's ctxt line, schedstat) time every -/// keystroke typed anywhere, and timer_list names other zones' tasks. Masking -/// stat hides its CPU counters too, so top shows no CPU use, vmstat will not -/// start, and libuv's os.cpus() and Java's load figures read nothing: the -/// price of closing the keystroke channel, not something to unmask. ps takes -/// its boot time from CLOCK_BOOTTIME and is unaffected. +/// /proc files hidden behind /dev/null: interrupt and context-switch counts time every keystroke, +/// and timer_list names other zones' tasks. Losing stat's CPU figures (top, vmstat) is the price. pub const PROC_MASKED: &[&str] = &["interrupts", "softirqs", "stat", "schedstat", "pressure/irq", "timer_list", "sched_debug"]; -/// Directories of a zone's /proc hidden behind an empty read-only tmpfs. -/// irq//spurious counts each interrupt of line n, the keyboard's included. +/// /proc directories hidden behind an empty tmpfs: irq//spurious counts keyboard interrupts too. pub const PROC_EMPTIED: &[&str] = &["irq"]; -/// What a zone other than the nic zone sees of sysfs: its own interfaces and -/// the CPU layout (glibc counts CPUs there). The rest describes the machine: -/// disk, USB and monitor serials, and which encrypted zones are running. +/// What a zone other than the nic zone sees of sysfs: its interfaces and the CPU layout (glibc +/// counts CPUs there). The rest holds disk and monitor serials and which encrypted zones run. pub const SYSFS_KEPT: &[&str] = &["class/net", "devices/virtual/net", "devices/system/cpu"]; -/// Host directories under /etc a zone may read, on the same terms as -/// `ETC_RO_FILES`. lynx will not start without its lynx.cfg. +/// Host directories under /etc a zone may read, as `ETC_RO_FILES`; lynx needs its lynx.cfg. pub const ETC_RO_DIRS: &[&str] = &["/etc/alternatives", "/etc/ssl/certs", "/etc/pki/tls/certs", "/etc/lynx"]; -/// The system allocator (ADR-005), which a zone preloads from the /usr it -/// shares read-only with zone 0. +/// The system allocator (ADR-005), preloaded in a zone from the /usr it shares with zone 0. pub const ALLOCATOR: &str = "/usr/lib/libhardened_malloc.so"; /// Device nodes a zone gets; no other exists for it. @@ -249,8 +227,7 @@ pub enum Resolver { None, /// A routed zone with a path: only the nic zone's bridge address is named. Bridge, - /// The nic zone, whose DHCP client writes it: the root is sealed, so - /// /etc/resolv.conf is a symlink into the zone's private /tmp. + /// The nic zone's DHCP client writes it, through a symlink into /tmp: the root is sealed. Writable, } @@ -258,9 +235,7 @@ pub fn resolv_conf_for_bridge() -> String { "nameserver 10.19.0.1\nnameserver fd19::1\n".to_string() } -/// Refuse an ephemeral zone whose persistent directory is not empty: leftover -/// data would sit on disk unwiped, and kryptikd must not delete what it did not -/// create. +/// Refuse an ephemeral zone over leftover data: it would sit unwiped, and is not ours to delete. pub fn check_data_dir_empty(path: &str, zone: &str) -> Result<(), RootfsError> { let entries = fs::read_dir(path) .map_err(|e| RootfsError::Setup(format!("{path}: {e}")))?; @@ -274,15 +249,12 @@ pub fn check_data_dir_empty(path: &str, zone: &str) -> Result<(), RootfsError> { } Err(RootfsError::Setup(format!( "ephemeral zone {zone:?} has persistent data in {path} from an earlier run \ - ({}); move or delete it. An ephemeral zone keeps nothing, so kryptikd will \ - not start one over data it did not write and must not silently destroy.", + ({}); move or delete it", leftovers.join(", ") ))) } -/// Refuse a data directory that is not a plain directory owned by the zone's -/// identity: a symlink would redirect the bind, and another owner's files would -/// be exposed to the zone. +/// Refuse a data directory that is a symlink, which would redirect the bind, or another owner's. pub fn check_data_dir(path: &str, expected_uid: u32) -> Result<(), RootfsError> { let md = fs::symlink_metadata(path) .map_err(|e| RootfsError::Setup(format!("{path}: {e}")))?; @@ -307,11 +279,8 @@ pub fn check_data_dir(path: &str, expected_uid: u32) -> Result<(), RootfsError> Ok(()) } -/// Replace the zone's root with a tree holding only what it should see, and -/// return the zone's home path. Runs after unshare(CLONE_NEWNS) and the uid map -/// (pivot_root needs CAP_SYS_ADMIN in the new user namespace) and before -/// Landlock and seccomp, which forbid mount and pivot_root. `ephemeral` is the -/// home tmpfs size of an ephemeral zone. +/// Pivot into a root holding only what the zone should see; returns the home. Runs as root in the +/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size. pub fn pivot_into( data_dir: &str, zone: &str, @@ -355,9 +324,8 @@ pub fn pivot_into( fd }; - /* The zone's root: a fresh tmpfs over the data directory's path. Every - * mount point below is created on it by kryptikd, so nothing the zone - * wrote can redirect a mount. */ + /* A fresh tmpfs over the data directory's path: kryptikd makes every mount point on it, so + * nothing the zone wrote can redirect a mount. */ let root = data_dir; mount_raw( "tmpfs", @@ -394,9 +362,8 @@ pub fn pivot_into( )?; mask_proc(root, &proc_dir)?; - /* Read-only sysfs for this zone's network namespace: the nic zone gets - * all of it, any other zone only `SYSFS_KEPT`, bound from a sysfs mounted - * aside and then dropped. Best effort: it can fail in a nested namespace. */ + /* Read-only sysfs: all of it for the nic zone, `SYSFS_KEPT` from a sysfs mounted aside for + * the others. Best effort: it can fail in a nested namespace. */ let sys_dir = mkdir("sys")?; let aside = if resolver == Resolver::Writable { sys_dir.clone() } else { mkdir(".sysfs")? }; let mounted = mount_raw( @@ -435,9 +402,8 @@ pub fn pivot_into( "mount(tmp)", )?; - /* The nic zone's private /run and /var/lib, for the network daemons' pid - * files, sockets and DHCP leases. Other zones have no /var, and a /run on - * the sealed root holding only what kryptikd binds there. */ + /* The nic zone's private /run and /var/lib, for its daemons' pid files, sockets and leases. + * Other zones have no /var, and in /run only what kryptikd binds there. */ if resolver == Resolver::Writable { for (rel, call) in [("run", "mount(nic /run tmpfs)"), ("var/lib", "mount(nic /var/lib tmpfs)")] { let d = mkdir(rel)?; @@ -452,16 +418,14 @@ pub fn pivot_into( } } - /* The nic zone's Wi-Fi credentials (wifi.rs): 0400, owned by the zone's - * identity, bound read-only. No file means no networks are configured. */ + // The nic zone's Wi-Fi credentials, bound read-only; no file means no networks configured. if let Some(conf) = wifi_conf { if fs::metadata(conf).map(|m| m.is_file()).unwrap_or(false) { bind_ro_file(conf, &format!("{root}{}", crate::wifi::IN_ZONE))?; } } - /* The zone's broker socket, 0600 and owned by the zone identity. - * connect(2) is not a Landlock filesystem access. */ + // The zone's broker socket; connect(2) is not a Landlock filesystem access. if let Some(sock) = broker { let rk = mkdir("run/kryptik")?; let target = format!("{rk}/{}", crate::broker::SOCKET_NAME); @@ -475,13 +439,11 @@ pub fn pivot_into( bind_ro_file(sock, &target)?; } - /* The zone's data at /home/, bound through the descriptor so it is - * the directory that was checked. Non-recursive, so mounts inside it are - * not carried in; with locked submounts the kernel says EINVAL. */ + /* The zone's data at /home/, bound through the descriptor that was checked. Not + * recursive, so mounts inside are not carried in (locked submounts give EINVAL). */ let home_dir = mkdir(&home[1..])?; if let Some(size) = ephemeral { - /* A tmpfs in the zone's own mount namespace: the kernel frees it with - * the namespace, even after kill -9. uid 0 is the zone's root. */ + // Freed with the zone's mount namespace, even after kill -9; uid 0 is the zone's root. mount_raw( "tmpfs", &home_dir, @@ -585,9 +547,8 @@ fn populate_etc(root: &str, zone: &str, home: &str, resolver: Resolver) -> Resul Ok(()) } -/// Hide `PROC_MASKED` behind /dev/null and `PROC_EMPTIED` behind an empty -/// tmpfs, and give the zone a boot_id of its own: the host's is the same in -/// every zone, so it would link them. +/// Hide `PROC_MASKED` and `PROC_EMPTIED`, and give the zone its own boot_id: the host's is the +/// same in every zone, so it would link them. fn mask_proc(root: &str, proc_dir: &str) -> Result<(), RootfsError> { for f in PROC_MASKED { let target = format!("{proc_dir}/{f}"); @@ -613,8 +574,7 @@ fn mask_proc(root: &str, proc_dir: &str) -> Result<(), RootfsError> { Ok(()) } -/// Bind `SYSFS_KEPT` from the sysfs mounted at `aside` into a read-only -/// tmpfs at `sys_dir`. +/// Bind `SYSFS_KEPT` from the sysfs at `aside` into a read-only tmpfs at `sys_dir`. fn keep_sysfs(aside: &str, sys_dir: &str) -> Result<(), RootfsError> { let flags = (libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC) as libc::c_ulong; mount_raw("tmpfs", sys_dir, Some("tmpfs"), flags, Some("mode=0755,size=64k"), "mount(sys tmpfs)")?; @@ -627,8 +587,7 @@ fn keep_sysfs(aside: &str, sys_dir: &str) -> Result<(), RootfsError> { mount_raw("none", sys_dir, None, flags | libc::MS_REMOUNT | libc::MS_RDONLY, None, "mount(sys tmpfs, ro)") } -/// A minimal /dev on tmpfs: the `DEVICES` nodes bound from the host (mknod would -/// need CAP_MKNOD there), a private /dev/shm and devpts, and the usual links. +/// A minimal /dev: `DEVICES` bound from the host (mknod needs CAP_MKNOD there), shm and devpts. fn populate_dev(root: &str) -> Result<(), RootfsError> { let dev_dir = format!("{root}/dev"); fs::create_dir_all(&dev_dir).map_err(|e| RootfsError::Setup(e.to_string()))?; @@ -692,8 +651,7 @@ fn populate_dev(root: &str) -> Result<(), RootfsError> { Ok(()) } -/// Open /dev/null on whichever of 0, 1, 2 is closed, so the zone's first open() -/// cannot become its stdout. +/// Open /dev/null on any closed 0, 1 or 2, so the zone's first open() cannot become its stdout. pub fn ensure_stdio() { let devnull = cs("/dev/null").expect("static path"); for fd in 0..=2 { @@ -708,9 +666,7 @@ pub fn ensure_stdio() { } } -/// Close every descriptor above stderr before handing control to the zone. -/// Neither Landlock nor pivot_root affects a descriptor already open, so only -/// 0, 1 and 2 are inherited; even one passed with `3 std::io::Result<()> { let r = unsafe { libc::syscall(libc::SYS_close_range, 3 as libc::c_uint, libc::c_uint::MAX, 0 as libc::c_uint) }; if r == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } diff --git a/compartments/kryptikd/src/rootfs/tests.rs b/compartments/kryptikd/src/rootfs/tests.rs index a24a4413..8f88ec05 100644 --- a/compartments/kryptikd/src/rootfs/tests.rs +++ b/compartments/kryptikd/src/rootfs/tests.rs @@ -1,7 +1,6 @@ use super::*; -/// Run `body` in a forked child and return its exit status: these tests -/// change descriptors and mount namespaces the test harness shares. +/// Run `body` in a forked child: these tests change descriptors and namespaces the harness shares. fn in_child(body: impl FnOnce() -> i32) -> i32 { let pid = unsafe { libc::fork() }; assert!(pid >= 0, "fork failed"); @@ -65,7 +64,7 @@ fn device_list_is_minimal() { } #[test] -fn etc_view_excludes_identity_and_secrets() { +fn etc_view_excludes_secrets() { let all: Vec<&str> = ETC_RO_FILES.iter().chain(ETC_RO_DIRS).copied().collect(); for banned in [ "/etc/machine-id", "/etc/hostname", "/etc/hosts", "/etc/passwd", "/etc/group", @@ -82,7 +81,7 @@ fn etc_view_excludes_identity_and_secrets() { } #[test] -fn bridge_resolver_names_only_bridge() { +fn bridge_resolver() { let r = resolv_conf_for_bridge(); assert_eq!(r.lines().count(), 2); assert!(r.contains("nameserver 10.19.0.1") && r.contains("nameserver fd19::1")); @@ -90,7 +89,7 @@ fn bridge_resolver_names_only_bridge() { } #[test] -fn identity_names_zone_not_host() { +fn identity_names_zone() { let pw = passwd_for("work", "/home/work"); assert!(pw.starts_with("root:x:0:0:work:/home/work:"), "{pw}"); assert_eq!(pw.lines().count(), 2); @@ -100,7 +99,7 @@ fn identity_names_zone_not_host() { } #[test] -fn data_dir_refuses_symlinks_other_owners() { +fn data_dir_refusals() { let base = std::env::temp_dir().join(format!("kryptik-rootfs-test-{}", std::process::id())); let real = base.join("real"); let link = base.join("link"); @@ -136,7 +135,7 @@ fn leftover_names_are_escaped() { } #[test] -fn close_inherited_fds_keeps_only_stdio() { +fn close_fds_keeps_stdio() { let rc = in_child(|| { open_at(3); open_at(4095); @@ -163,7 +162,7 @@ fn close_inherited_fds_keeps_only_stdio() { } #[test] -fn ensure_stdio_reopens_closed_fd() { +fn ensure_stdio_reopens() { let rc = in_child(|| { unsafe { libc::close(0) }; if is_open(0) { @@ -178,12 +177,10 @@ fn ensure_stdio_reopens_closed_fd() { assert_eq!(rc, 0); } -/// A submount inside the bound tree must be read-only too. Needs an -/// unprivileged user namespace; skips without one. +/// A submount in the bound tree must be read-only too; skips without an unprivileged userns. #[test] -fn read_only_bind_is_recursive() { - /* Before the fork: temp_dir() takes std's environment lock, which - * another test thread may hold at fork time, hanging the child. */ +fn ro_bind_recursive() { + // Before the fork: temp_dir() takes std's env lock, which, held at fork time, hangs the child. let base = std::env::temp_dir().join(format!("kryptik-ro-test-{}", std::process::id())); let rc = in_child(|| { let uid = unsafe { libc::getuid() }; diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index 6223b102..59966447 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -1,6 +1,5 @@ -//! seccomp-bpf syscall filtering: a default-deny allowlist -//! (docs/architecture.md), built as classic BPF by hand rather than with -//! libseccomp, a large C dependency (ADR-010). +//! Default-deny seccomp-bpf syscall allowlist (docs/architecture.md), hand-built as classic BPF +//! instead of with libseccomp, a large C dependency (ADR-010). use std::io; use std::os::unix::io::RawFd; @@ -27,32 +26,27 @@ const BPF_RET: u16 = 0x06; // Filter return actions. const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; -/* Fail the call instead of killing, for programs that probe for a feature and - * must hear "no": clone3, unwanted socket families, `REFUSED_SOFTLY`. */ +// Fail instead of kill, for programs that probe a feature and must hear "no". const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; // Offsets into struct seccomp_data. const OFF_NR: u32 = 0; const OFF_ARCH: u32 = 4; -/* Low 32 bits of args[i] (little-endian). The kernel reads every argument - * checked here as 32 bits: clone flags, ioctl cmd, socket family, protocol. */ +// Low 32 bits of args[i] (little-endian); the kernel reads every argument checked here as 32 bits. const fn arg_lo(i: u32) -> u32 { 16 + 8 * i } -/// Namespace-creating clone(2) flags: clone with any of them is unshare(2) by -/// another name. Not CLONE_NEWTIME (0x80): legacy clone's low byte is the exit -/// signal, which the kernel strips. +/// clone(2) flags that create namespaces, making clone an unshare(2). Not CLONE_NEWTIME (0x80): +/// legacy clone's low byte is the exit signal, which the kernel strips. pub const CLONE_NS_MASK: u32 = 0x7e02_0000; -/// ioctls that inject into or read from the terminal: with TIOCSTI, a zone on -/// the user's tty types into the user's shell. Denied whatever LEGACY_TIOCSTI is. +/// ioctls that inject into or read the tty: TIOCSTI types into the user's shell. Always denied. const TIOCSTI: u32 = 0x5412; const TIOCLINUX: u32 = 0x541C; -/// Socket families a zone may open. The rest (AF_VSOCK reaches the host; -/// AF_ALG, AF_PACKET, AF_XDP, ...) are CVE-prone and unneeded, though a policy -/// may add some. AF_NETLINK only as NETLINK_ROUTE, for getifaddrs() and ip(8). +/// Socket families a zone may open; the rest (AF_VSOCK reaches the host; AF_ALG, AF_PACKET, +/// AF_XDP ...) are CVE-prone and unneeded, though a policy may add some. pub const AF_UNIX: u32 = 1; pub const AF_INET: u32 = 2; pub const AF_INET6: u32 = 10; @@ -62,7 +56,7 @@ const EAFNOSUPPORT: u32 = 97; const ENOSYS: u32 = 38; const EPERM: u32 = 1; -/// Families the base policy allows (AF_NETLINK only as NETLINK_ROUTE). +/// Families the base policy allows; AF_NETLINK only as NETLINK_ROUTE, for getifaddrs() and ip(8). pub const BASE_SOCKET_FAMILIES: &[u32] = &[AF_UNIX, AF_INET, AF_INET6, AF_NETLINK]; /// The families a zone policy may name. Numbers from . @@ -145,11 +139,9 @@ impl std::fmt::Display for SeccompError { SeccompError::TooManyRules(n) => { write!(f, "filter would be {n} instructions; the kernel limit is 4096") } - SeccompError::BadSyscallNumber(nr) => write!( - f, - "syscall number {nr} is out of range for a 32-bit comparison; \ - refusing to build a filter that would compare a truncated value" - ), + SeccompError::BadSyscallNumber(nr) => { + write!(f, "syscall number {nr} does not fit the filter's 32-bit comparison") + } SeccompError::Syscall { call, errno } => { write!(f, "{call}: {}", io::Error::from_raw_os_error(*errno)) } @@ -216,14 +208,11 @@ syscalls! { libc::SYS_openat2, libc::SYS_close_range, // FIFOs and sockets; Landlock and nodev stop device nodes. libc::SYS_mknod, libc::SYS_mknodat, - /* tar, git, cargo and install(1) set modes. Paths outside the zone are - * unreachable after pivot_root, and read-only mounts refuse chmod (EROFS). */ + // tar, git and install(1) set modes; pivot_root hides other paths and read-only mounts refuse. libc::SYS_chmod, libc::SYS_fchmod, libc::SYS_fchmodat, // glibc 2.39 and later make fchmodat with flags (rsync -a) this call. libc::SYS_fchmodat2, - /* gzip, xz and cp -a give a file they make its source's owner, and tar - * does as root. Without CAP_CHOWN, and with only the zone's own ids - * mapped, a chown is a no-op or fails. */ + // gzip, xz, cp -a and tar copy owners; without CAP_CHOWN a chown is a no-op or fails. libc::SYS_chown, libc::SYS_fchown, libc::SYS_lchown, libc::SYS_fchownat, libc::SYS_copy_file_range, libc::SYS_sendfile, libc::SYS_splice, // GNU cat and cp call posix_fadvise() on every file they read. @@ -233,9 +222,7 @@ syscalls! { libc::SYS_sync, libc::SYS_syncfs, libc::SYS_getxattr, libc::SYS_lgetxattr, libc::SYS_fgetxattr, libc::SYS_listxattr, libc::SYS_llistxattr, libc::SYS_flistxattr, - /* install(1) resets a file's ACL through them, as do cp and tar keeping - * xattrs. A zone reaches only user.* and the ACLs of its own files: the - * security., trusted. and capability names need capabilities it lacks. */ + // install, cp and tar set ACLs and xattrs; a zone reaches only user.* and its own files' ACLs. libc::SYS_setxattr, libc::SYS_lsetxattr, libc::SYS_fsetxattr, libc::SYS_removexattr, libc::SYS_lremovexattr, libc::SYS_fremovexattr, @@ -244,18 +231,14 @@ syscalls! { libc::SYS_madvise, libc::SYS_mlock, libc::SYS_munlock, libc::SYS_memfd_create, // Python's mmap.flush and databases that map their files. libc::SYS_msync, - /* Every dynamic linker needs mprotect, though it can defeat W^X; RELRO and - * BIND_NOW make the GOT read-only before main() (docs/hardening.md). */ + // Dynamic linkers need it, though it can defeat W^X; RELRO and BIND_NOW seal the GOT first. libc::SYS_mprotect, - // --- process / thread lifecycle --- - /* clone is argument-filtered; clone3 gets ENOSYS, since the filter cannot - * read its struct and glibc falls back to clone. See `ARG_RULES`. */ + // --- process / thread lifecycle (`ARG_RULES` check clone's flags and give clone3 ENOSYS) --- libc::SYS_clone, libc::SYS_clone3, libc::SYS_fork, libc::SYS_vfork, libc::SYS_execve, libc::SYS_execveat, libc::SYS_exit, libc::SYS_exit_group, libc::SYS_wait4, libc::SYS_waitid, - /* Python's asyncio (3.12) watches its children through a pidfd. It names - * a process the zone can already see and kill; pidfd_getfd stays out. */ + // Python's asyncio watches children by pidfd, which reaches only processes the zone can kill. libc::SYS_pidfd_open, libc::SYS_pidfd_send_signal, libc::SYS_getpid, libc::SYS_getppid, libc::SYS_gettid, libc::SYS_getuid, libc::SYS_geteuid, libc::SYS_getgid, libc::SYS_getegid, @@ -282,8 +265,7 @@ syscalls! { libc::SYS_clock_gettime, libc::SYS_clock_getres, libc::SYS_clock_nanosleep, libc::SYS_gettimeofday, libc::SYS_nanosleep, libc::SYS_times, libc::SYS_alarm, libc::SYS_setitimer, libc::SYS_getitimer, libc::SYS_pause, - /* POSIX timers, which coreutils timeout(1) and vim arm: like setitimer, - * they signal the caller's own process. */ + // POSIX timers for timeout(1) and vim; like setitimer, they signal only the caller's process. libc::SYS_timer_create, libc::SYS_timer_settime, libc::SYS_timer_gettime, libc::SYS_timer_getoverrun, libc::SYS_timer_delete, @@ -369,8 +351,7 @@ denied! { ] } -/// Argument checks on allowlisted syscalls. They run before the plain -/// allowlist, so a syscall named here is decided here. +/// Argument checks, run before the plain allowlist, so a syscall named here is decided here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ArgRule { /// clone(2): kill if any CLONE_NEW* flag is set in args[0]. @@ -381,8 +362,7 @@ pub enum ArgRule { IoctlNoTtyInject, /// socket(2): the base families plus the policy's; others get EAFNOSUPPORT. SocketFamilies, - /// socketpair(2): AF_UNIX only. The kernel runs a family's create code, - /// module autoload included, before it asks for a pair. + /// socketpair(2): AF_UNIX only; other families' create code and module autoload run first. SocketpairUnix, } @@ -406,16 +386,15 @@ impl ArgRule { } } -/// Refused with an errno instead of killed: programs carry on or say why. -/// inotify: a watch on the /usr the zones share with zone 0 sees every program -/// any of them starts; a zone policy may allow it. The id and capability calls -/// never succeed, but ncurses brackets every terminfo open with setfsuid and -/// setfsgid, and sudo, su and privilege-dropping daemons call the rest. +/// Refused with an errno instead of killed, so programs carry on or say why. pub const REFUSED_SOFTLY: &[(libc::c_long, u32)] = &[ + // A watch on the /usr shared with zone 0 sees every program started; a policy may allow it. (libc::SYS_inotify_init, ENOSYS), (libc::SYS_inotify_init1, ENOSYS), + // Denied, but ncurses brackets every terminfo open with these two. (libc::SYS_setfsuid, EPERM), (libc::SYS_setfsgid, EPERM), + // Denied, but sudo, su and privilege-dropping daemons call these. (libc::SYS_setuid, EPERM), (libc::SYS_setgid, EPERM), (libc::SYS_setreuid, EPERM), @@ -430,9 +409,8 @@ const fn errno_action(e: u32) -> u32 { SECCOMP_RET_ERRNO | (e & 0xffff) } -/// Emit one argument rule. Entered with the syscall number in the accumulator; -/// a non-matching number skips the block with the accumulator intact, and every -/// path through a matched block ends in `ret`. +/// Emit one argument rule. Another syscall number skips the block with the accumulator intact, +/// and every path through a matched block ends in `ret`. fn emit_arg_rule(p: &mut Vec, rule: ArgRule, deny_action: u32, sockets: &SocketPolicy) { let body: Vec = match rule { ArgRule::CloneNoNamespaces => vec![ @@ -459,8 +437,7 @@ fn emit_arg_rule(p: &mut Vec, rule: ArgRule, deny_action: u32, socke * ret ALLOW * ret ERRNO(EAFNOSUPPORT) * - * A miss on the last comparison jumps to DENY; others fall through. - */ + * A miss on the last comparison jumps to DENY; others fall through. */ ArgRule::SocketFamilies => { let mut fams: Vec = vec![AF_UNIX, AF_INET, AF_INET6]; for f in &sockets.families { @@ -533,9 +510,8 @@ fn build_program_with( build_program_full(allow, deny_action, &SocketPolicy::default()) } -/// Build the BPF program: arch check, x32 check, argument rules, then a -/// `jeq nr; ret ALLOW` pair per syscall, so no jump offset (one byte) grows -/// with the list, then default deny. +/// Arch check, x32 check, argument rules, soft refusals, then a `jeq nr; ret ALLOW` pair per +/// syscall, so no one-byte jump offset grows with the list, then default deny. fn build_program_full( allow: &[libc::c_long], deny_action: u32, @@ -549,8 +525,7 @@ fn build_program_full( p.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR)); - /* x32 calls arrive as 0x40000000 | nr, so this is >=, not ==. Default deny - * would catch them too; this is defence in depth. */ + // x32 calls arrive as 0x40000000 | nr, hence >=; default deny would catch them too. p.push(jump(BPF_JMP | BPF_JGE | BPF_K, X32_SYSCALL_BIT, 0, 1)); p.push(stmt(BPF_RET | BPF_K, deny_action)); @@ -564,8 +539,7 @@ fn build_program_full( for &(nr, e) in REFUSED_SOFTLY { // Allowed by the list, it is allowed below like any other. if !allow.contains(&nr) { - /* seccomp-trace answers these with the same errno, so the program - * runs as it would in a zone and the call is still named. */ + // seccomp-trace names the call and answers with the same errno, as in a zone. let refuse = if deny_action == libc::SECCOMP_RET_USER_NOTIF { deny_action } else { errno_action(e) }; p.push(jump(BPF_JMP | BPF_JEQ | BPF_K, nr as u32, 0, 1)); p.push(stmt(BPF_RET | BPF_K, refuse)); @@ -594,15 +568,13 @@ fn build_program_full( Ok(p) } -/// Install a default-deny filter on every thread of the process (TSYNC) and -/// all descendants. Irreversible. +/// Install a default-deny filter on every thread (TSYNC) and all descendants; irreversible. pub fn install(allow: &[libc::c_long]) -> Result<(), SeccompError> { install_with(allow, SECCOMP_RET_KILL_PROCESS, &SocketPolicy::default(), SECCOMP_FILTER_FLAG_TSYNC).map(|_| ()) } -/// As `install` for a single-threaded caller, but a refused call waits for a -/// supervisor instead of killing: returns the listener descriptor, which is -/// close-on-exec (`kryptikd seccomp-trace`). +/// `install` for a single-threaded caller, but a refused call waits for a supervisor; returns +/// the close-on-exec listener (`kryptikd seccomp-trace`). pub fn install_notifying(allow: &[libc::c_long], sockets: &SocketPolicy) -> Result { let fd = install_with(allow, libc::SECCOMP_RET_USER_NOTIF, sockets, libc::SECCOMP_FILTER_FLAG_NEW_LISTENER)?; Ok(fd as RawFd) @@ -649,8 +621,7 @@ fn install_with( errno: io::Error::last_os_error().raw_os_error().unwrap_or(0), }); } - /* TSYNC names a thread it could not synchronise by returning its id, and - * attaches nothing; only a listener is otherwise positive. */ + // Without NEW_LISTENER, a positive return is a thread TSYNC could not sync; nothing attached. if ret > 0 && flags & libc::SECCOMP_FILTER_FLAG_NEW_LISTENER == 0 { return Err(SeccompError::Unsynced(ret)); } @@ -662,14 +633,12 @@ pub fn confine_zone() -> Result<(), SeccompError> { install(BASE_ALLOWLIST) } -/// Install the zone filter widened by a policy: `extra` syscalls and the -/// socket rule widened by `sockets`. +/// Install the zone filter widened by a policy's `extra` syscalls and `sockets` rule. pub fn confine_zone_with(extra: &[libc::c_long], sockets: &SocketPolicy) -> Result<(), SeccompError> { install_with(&widened(extra)?, SECCOMP_RET_KILL_PROCESS, sockets, SECCOMP_FILTER_FLAG_TSYNC).map(|_| ()) } -/// The base allowlist and a policy's `extra` syscalls, each checked again -/// against the denied list. +/// The base allowlist plus a policy's `extra` syscalls, each checked again against the denied list. pub fn widened(extra: &[libc::c_long]) -> Result, SeccompError> { let mut allow: Vec = BASE_ALLOWLIST.to_vec(); for &nr in extra { @@ -695,8 +664,7 @@ pub const ADDABLE: &[(&str, libc::c_long)] = by_name![ SYS_syslog, SYS_vhangup, ]; -/// Every syscall a policy file may name: a denied one is refused, a base one -/// warns, and an `ADDABLE` one is added. +/// Syscalls a policy may name: denied ones are refused, base ones warn, `ADDABLE` ones are added. pub fn names() -> impl Iterator { DENIED_NAMES.iter().chain(BASE_NAMES).chain(ADDABLE) } diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index ad9a4f52..f5efcc79 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -41,8 +41,7 @@ fn arch_checked_before_syscall_number() { assert!(p.iter().take(3).any(|i| i.k == AUDIT_ARCH_X86_64)); } -/// Interprets the BPF subset `build_program` emits, so tests check what the -/// filter does rather than which constants it holds. +/// Interprets what `build_program` emits, so tests check what the filter does, not its constants. fn evaluate(prog: &[SockFilter], arch: u32, nr: u32) -> u32 { evaluate_args(prog, arch, nr, [0; 6]) } @@ -190,9 +189,7 @@ fn whole_allowlist_evaluates_correctly() { let p = build_program(BASE_ALLOWLIST).unwrap(); for &nr in BASE_ALLOWLIST { let r = evaluate(&p, AUDIT_ARCH_X86_64, nr as u32); - /* With zero arguments clone3 gets ENOSYS and family 0 is refused - * to socket and socketpair; nothing listed is killed, and the rest - * are allowed. */ + // With zero arguments clone3 gets ENOSYS and family 0 is refused; nothing listed is killed. assert_ne!(r, SECCOMP_RET_KILL_PROCESS, "allowlisted syscall {nr} was killed"); if ![libc::SYS_clone3, libc::SYS_socket, libc::SYS_socketpair].contains(&nr) { assert_eq!(r, SECCOMP_RET_ALLOW, "allowlisted syscall {nr} was not allowed"); @@ -254,9 +251,9 @@ fn with_arg(i: usize, v: u64) -> [u64; 6] { } #[test] -fn clone_without_namespace_flags_is_allowed() { +fn plain_clone_allowed() { let p = build_program(BASE_ALLOWLIST).unwrap(); - // What pthread_create and fork() actually pass. + // What pthread_create and fork() pass. let thread = 0x3d0f00u64; // VM|FS|FILES|SIGHAND|THREAD|SYSVSEM|SETTLS|PARENT_SETTID|CHILD_CLEARTID let fork = 0x1200011u64; // CHILD_SETTID|CHILD_CLEARTID|SIGCHLD for f in [thread, fork, 17] { @@ -269,7 +266,7 @@ fn clone_without_namespace_flags_is_allowed() { } #[test] -fn clone_with_any_namespace_flag_is_killed() { +fn namespace_clone_killed() { let p = build_program(BASE_ALLOWLIST).unwrap(); for f in [ libc::CLONE_NEWUSER, libc::CLONE_NEWNS, libc::CLONE_NEWPID, @@ -362,9 +359,8 @@ fn socketpair_unix_only() { #[test] fn tsync_failure_is_error() { - /* A thread with a filter of its own cannot take another by TSYNC: the - * kernel returns its id and attaches nothing. In a forked child, which - * exits while that thread still waits. */ + /* TSYNC fails on a thread with a filter of its own: the kernel returns its id and attaches + * nothing. Run in a forked child, which exits while that thread still waits. */ let pid = unsafe { libc::fork() }; assert!(pid >= 0); if pid == 0 { @@ -399,8 +395,7 @@ fn tsync_failure_is_error() { #[test] fn arg_rules_leave_allowlist_alone() { - /* A non-matching rule block must leave the syscall number in the - * accumulator; these arguments would trip any rule consulted. */ + // A skipped rule block must leave the syscall number loaded; these arguments trip any rule. let p = build_program(BASE_ALLOWLIST).unwrap(); let args = [CLONE_NS_MASK as u64, TIOCSTI as u64, 40, 7, 1 << 33, 9]; for &nr in BASE_ALLOWLIST { @@ -425,8 +420,7 @@ fn widened_refuses_denied() { #[test] fn everyday_calls_allowed() { - /* Each killed a common program in a zone: tar, gzip, cp -a, rsync, - * install, asyncio, timeout, mmap.flush, chrt -p. */ + // tar, gzip, cp -a, rsync, install, asyncio, timeout, mmap.flush and chrt -p need these. let allowed: HashSet = BASE_ALLOWLIST.iter().copied().collect(); for nr in [ libc::SYS_chmod, libc::SYS_fchmod, libc::SYS_fchmodat, libc::SYS_fchmodat2, @@ -445,18 +439,15 @@ fn everyday_calls_allowed() { // --- zone policy widenings --------------------------------------------- #[test] -fn widened_socket_rule_allows_named_extras() { +fn socket_policy_extras() { let sp = SocketPolicy { families: vec![17], netlink_protocols: vec![12], netlink_all: false }; let p = build_program_full(BASE_ALLOWLIST, SECCOMP_RET_KILL_PROCESS, &sp).unwrap(); - // The base families still pass... for fam in [AF_UNIX, AF_INET, AF_INET6] { assert_eq!(evaluate_args(&p, X86, libc::SYS_socket as u32, with_arg(0, fam as u64)), SECCOMP_RET_ALLOW, "{fam}"); } - // ...the extra family passes... assert_eq!(evaluate_args(&p, X86, libc::SYS_socket as u32, with_arg(0, 17)), SECCOMP_RET_ALLOW); - // ...an unnamed family still does not... assert_eq!(evaluate_args(&p, X86, libc::SYS_socket as u32, with_arg(0, 40)), errno_action(EAFNOSUPPORT)); - // ...NETLINK_ROUTE and the extra protocol pass, another does not. + // NETLINK_ROUTE and the extra protocol pass, another does not. let mut a = [0u64; 6]; a[0] = AF_NETLINK as u64; for (proto, want) in [(0u64, SECCOMP_RET_ALLOW), (12, SECCOMP_RET_ALLOW), (9, errno_action(EAFNOSUPPORT))] { diff --git a/compartments/kryptikd/src/serve/tests.rs b/compartments/kryptikd/src/serve/tests.rs index b4514de4..2ff39090 100644 --- a/compartments/kryptikd/src/serve/tests.rs +++ b/compartments/kryptikd/src/serve/tests.rs @@ -40,7 +40,7 @@ fn request_complete_at_terminator() { } #[test] -fn parse_wifi_keeps_values_hides_psk() { +fn parse_wifi_hides_psk() { let r = parse_wifi("wifi-add\nssid Cafe Wifi \npsk pass word\nend\n").unwrap(); assert_eq!(r.ssid, "Cafe Wifi "); assert_eq!(r.psk.as_deref(), Some("pass word")); @@ -62,7 +62,7 @@ fn parse_wifi_keeps_values_hides_psk() { /// The path rule: only the session's own `/wayland-0` is considered. #[test] -fn proxy_path_must_be_sessions_own() { +fn proxy_path_rule() { let bad = |p: &str, uid: u32, zone: &str| { let e = verify_proxy_socket(Path::new(p), uid, zone, None).unwrap_err(); assert!(e.contains("must be") || e.contains("path"), "{p}: {e}"); @@ -78,7 +78,7 @@ fn proxy_path_must_be_sessions_own() { // The ownership and listener checks run against the daemon in compartments/tests/serve.sh. #[test] -fn open_nofollow_refuses_links_and_non_sockets() { +fn open_nofollow_refusals() { let dir = std::env::temp_dir().join(format!("kryptik-nofollow-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("real")).unwrap(); @@ -95,7 +95,7 @@ fn open_nofollow_refuses_links_and_non_sockets() { } #[test] -fn full_proxy_backlog_does_not_block() { +fn full_backlog_never_blocks() { let dir = std::env::temp_dir().join(format!("kryptik-backlog-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("socket"); diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 3770a008..891dda0b 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -58,13 +58,11 @@ pub struct RunOptions { pub zone_gid: Option, /// The zone directory, against which `[policy]` paths resolve. pub zones_dir: std::path::PathBuf, - /// Wi-Fi credentials directory (wifi.rs); empty means the default. Only a - /// nic zone gets its wpa_supplicant.conf, read-only at /etc/wpa_supplicant.conf. + /// Wi-Fi credentials directory (wifi.rs), empty for the default; only a nic zone gets its file. pub wifi_dir: std::path::PathBuf, /// Development only: approve every transfer this zone offers, unprompted. pub auto_approve_transfers: bool, - /// An encrypted zone's passphrase, from a 0600 file (tests, root at a - /// terminal). A passphrase is never read from argv or the environment. + /// An encrypted zone's passphrase from a 0600 file (tests, root); never argv or environment. pub passphrase_file: Option, /// Or from an inherited descriptor: what the trusted prompt collected. pub passphrase_fd: Option, @@ -72,13 +70,11 @@ pub struct RunOptions { pub wayland_socket: Option, /// The inode the launch daemon verified; any other at that path is refused. pub wayland_inode: Option, - /// The launch daemon's readiness pipe (serve.rs): gets `ready` and is closed - /// once the zone's pid 1 exists; EOF before that means the launch failed. + /// Readiness pipe from serve.rs: `ready` once pid 1 exists; EOF before that means failure. pub ready_fd: Option, } -/// Open a Wayland socket without following symlinks, and refuse any inode but -/// the one the launch daemon verified, so a rename since its check fails. +/// Open a Wayland socket without following symlinks, refusing any inode but the verified one. fn open_wayland_socket(path: &std::path::Path, inode: Option) -> Result { let fd = crate::serve::open_nofollow(path, true).map_err(|e| format!("wayland socket {e}"))?; if let Some(want) = inode { @@ -93,9 +89,7 @@ fn open_wayland_socket(path: &std::path::Path, inode: Option/wayland-0` in the host -/// mount namespace, for the child to open after unshare (see run_in_zone). -/// Drop detaches the bind and removes the mountpoint. +/// The proxy socket bound into the registry entry, for the child to open after unshare. struct StagedSocket { path: std::path::PathBuf, } @@ -115,8 +109,7 @@ impl StagedSocket { let path = entry_dir.join(rootfs::WAYLAND_SOCKET_NAME); let cpath = CString::new(path.display().to_string()) .map_err(|_| SpawnError::Setup("registry path contains a NUL".into()))?; - /* Detach every bind a dead launcher left (each umount takes only the - * topmost), then create the mountpoint new, so nothing planted is reused. */ + // Unmount every stale bind and recreate the mountpoint, so nothing planted is reused. while unsafe { libc::umount2(cpath.as_ptr(), libc::MNT_DETACH) } == 0 {} let _ = std::fs::remove_file(&path); std::fs::OpenOptions::new() @@ -154,12 +147,9 @@ impl Drop for StagedSocket { /// Seconds after a forwarded signal before pid 1 is SIGKILLed, and its pid namespace with it. pub const GRACE_SECS: u32 = 5; -// --- signal forwarding ------------------------------------------------------ -/* The parent and the intermediate each run this with their own statics: - * FORWARD_TO is the target; ARM_KILL (the intermediate, for pid 1) also arms - * the SIGKILL alarm. The handlers are async-signal-safe. */ - +// Signal forwarding, used by the parent and the intermediate; the handlers are async-signal-safe. static FORWARD_TO: AtomicI32 = AtomicI32::new(0); +/// Set in the intermediate: a forwarded signal also arms the SIGKILL alarm for pid 1. static ARM_KILL: AtomicBool = AtomicBool::new(false); extern "C" fn forward_signal(sig: libc::c_int) { @@ -203,8 +193,7 @@ fn install_forwarding(target: libc::pid_t, arm_kill: bool) { } } -/// One log line in a single write(2), which another writer on the descriptor -/// cannot split (a pipe write under PIPE_BUF is atomic; `eprintln!` writes in +/// One log line in a single write(2), atomic on a pipe under PIPE_BUF (`eprintln!` writes in /// pieces). Raw fd 2: `io::stderr()`'s lock can be inherited held across fork. pub(crate) fn log_line(line: &str) { let mut bytes = Vec::with_capacity(line.len() + 1); @@ -223,14 +212,13 @@ pub(crate) fn log_line(line: &str) { } } -/// Most zone output one launch logs (the rest is read and dropped, so an endless -/// writer neither blocks nor fills the state partition), and the longest line. +/// Most zone output a launch logs, and its longest line. The rest is read and dropped, so an +/// endless writer neither blocks nor fills the state partition. const ZONE_OUTPUT_MAX: usize = 1 << 20; const ZONE_LINE_MAX: usize = 1024; -/// Relays a daemon-launched zone's output into the launcher's log. The zone -/// gets a pipe, never the log: O_APPEND does not stop ftruncate or fallocate. -/// Lines are marked as the zone's, control bytes replaced, the total bounded. +/// Relays a daemon-launched zone's output into the launcher's log, marked, sanitized and bounded. +/// The zone gets a pipe, never the log: O_APPEND does not stop ftruncate or fallocate. struct ZoneOutput { fd: RawFd, mark: String, @@ -271,8 +259,7 @@ impl ZoneOutput { } } - /// Read at most `reads` 4 KiB pieces; false once every writer has gone. - /// Bounded, so a zone that never stops writing cannot hold the launcher here. + /// Read at most `reads` 4 KiB pieces, so an endless writer cannot hold us; false at EOF. fn pump_at_most(&mut self, reads: usize, emit: &mut dyn FnMut(&str)) -> bool { let mut buf = [0u8; 4096]; let mut done = 0; @@ -308,8 +295,7 @@ impl Drop for ZoneOutput { } } -/// Bounds the broker's lines as ZoneOutput bounds the zone's output: a zone -/// can send a request every millisecond, and each one is a line. +/// Bounds the broker's log lines like ZoneOutput: a zone can send a request every millisecond. struct BrokerLog { zone: String, left: usize, @@ -338,16 +324,14 @@ impl BrokerLog { } } -/// Whether the child has exited, without reaping it: WNOWAIT leaves it for -/// the waitpid that decides the launcher's status. +/// Whether the child has exited, without reaping it: the waitpid in serve_until_exit does that. fn exited_unreaped(pid: libc::pid_t) -> bool { let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; let r = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOHANG | libc::WNOWAIT) }; r == 0 && unsafe { info.si_pid() } != 0 } -/// Serve the zone's broker socket and relay its output until the child exits. -/// Forwarded signals interrupt the poll, which just loops. +/// Serve the broker socket and relay the zone's output until the child exits (EINTR re-polls). fn serve_until_exit(pid: libc::pid_t, listen_fd: RawFd, s: &broker::Served, out: Option) -> Result { let zone = s.zone.name.as_str(); let out = std::cell::RefCell::new(out); @@ -357,9 +341,8 @@ fn serve_until_exit(pid: libc::pid_t, listen_fd: RawFd, s: &broker::Served, out: *o = None; // every writer has gone } }; - /* While a consent question is open the broker calls this ten times a - * second: output keeps flowing, and a zone that has exited withdraws its - * question. The child is only looked at; the loop below reaps it. */ + /* The broker calls this ten times a second while a consent question is open: output keeps + * flowing, and a zone that has exited withdraws its question. The loop below reaps it. */ let asking = || { pump(); !exited_unreaped(pid) @@ -367,9 +350,8 @@ fn serve_until_exit(pid: libc::pid_t, listen_fd: RawFd, s: &broker::Served, out: let blog = std::cell::RefCell::new(BrokerLog::new(zone)); let log = |line: &str| blog.borrow_mut().line(line, &mut |l: &str| log_line(l)); let s = &broker::Served { asking: &asking, log: &log, ..*s }; - /* A pidfd is readable once the zone has ended, so the poll needs no - * timeout. Without one it wakes every 200 ms: failing here would skip - * the caller's closing of the zone's volume. */ + /* A pidfd is readable once the zone ends, so the poll needs no timeout. Without one it + * wakes every 200 ms, as failing here would skip the caller's closing of the volume. */ let pidfd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as RawFd; let _pidfd = (pidfd >= 0).then(|| unsafe { OwnedFd::from_raw_fd(pidfd) }); let timeout = if pidfd >= 0 { -1 } else { 200 }; @@ -515,8 +497,7 @@ impl SyncPipe { Some(i32::from_ne_bytes(b)) } - /// Whether every write end is closed, without waiting. An error counts as - /// closed: the caller must not go on unchecked. + /// Whether every write end is closed, without waiting; an error counts as closed. fn peer_gone(&self) -> bool { let mut p = libc::pollfd { fd: self.read, events: libc::POLLIN, revents: 0 }; loop { @@ -563,12 +544,10 @@ fn launch_identity(opts: &RunOptions, zone: &Zone) -> Result Ok(Identity { uid, gid, privileged: true }), _ => Err(SpawnError::Setup(format!( - "kryptikd is running as root and zone {:?} declares no [identity]. \ - Mapping the zone's root to host uid 0 would make every permission \ - check inside the zone succeed as the real superuser on everything the \ - zone can reach. Add `[identity] uid_base = N` to the zone file (a \ - multiple of 65536, at least 131072), or pass --zone-uid UID --zone-gid \ - GID (both non-zero), or run kryptikd unprivileged.", + "kryptikd runs as root and zone {:?} declares no [identity], so its root would be \ + host uid 0. Add `[identity] uid_base = N` to the zone file (a multiple of 65536, \ + at least 131072), pass --zone-uid UID --zone-gid GID (both non-zero), or run \ + kryptikd unprivileged.", zone.name ))), } @@ -588,8 +567,7 @@ fn launch_identity(opts: &RunOptions, zone: &Zone) -> Result { return Err(SpawnError::Confine( @@ -635,15 +612,13 @@ pub fn run_in_zone( if opts.auto_approve_transfers { eprintln!( "kryptikd: WARNING: --auto-approve-transfers: every file zone {:?} offers to another \ - zone is approved without a prompt (development flag; the prompt is desktop work)", + zone is approved without a prompt (development flag)", zone.name ); } - /* KRYPTIK_EXPERIMENTAL=1 starts a zone without guarantees this build cannot - * give. Environment only, never a config setting, so it is always a - * conscious act. A root launch on a kernel that restricts unprivileged user - * namespaces ignores it (docs/design/privileged-launch.md). */ + /* KRYPTIK_EXPERIMENTAL=1 waives guarantees this build cannot give: environment only, so + * always a conscious act. A root launch on a userns-restricting kernel ignores it. */ let mut experimental = std::env::var("KRYPTIK_EXPERIMENTAL").as_deref() == Ok("1"); if experimental && unsafe { libc::geteuid() } == 0 { if let Some((true, knob)) = isolate::userns_restriction_sysctl() { @@ -656,29 +631,21 @@ pub fn run_in_zone( } let mut unsupported: Vec = Vec::new(); match zone.storage { - /* Only a root launch can open the LUKS2 volume. Refused outright, not - * waivable by KRYPTIK_EXPERIMENTAL, which would run the zone on a plain - * directory. */ + // Not waivable by KRYPTIK_EXPERIMENTAL, which would run the zone on a plain directory. StorageMode::Encrypted if unsafe { libc::geteuid() } != 0 => { return Err(SpawnError::Setup(format!( - "zone {:?} is encrypted: its LUKS2 volume is opened by a root launch with the \ - passphrase from the trusted prompt (kryptik-launch) or --passphrase-file; an \ - unprivileged launch cannot open it (cryptsetup, dm-crypt, loop devices) and \ - will not run the zone on a plain directory instead. KRYPTIK_EXPERIMENTAL does \ - not change this.", + "zone {:?} is encrypted: only a root launch can open its LUKS2 volume \ + (kryptik-launch, or --passphrase-file as root), and it never runs on a plain \ + directory instead, KRYPTIK_EXPERIMENTAL or not", zone.name ))); } StorageMode::Encrypted => {} - /* A per-launch tmpfs; the persistent directory is never bound - * (docs/design/resource-limits-and-ephemeral-zones.md). */ StorageMode::Ephemeral => {} - // The zone's own directory, bound at $HOME and kept between launches. StorageMode::Persistent => {} } - /* A Landlock policy file narrows the base rules (docs/design/zone-policy-files.md). - * Parsed here: the zone cannot reach the zone directory once it has pivoted, - * and a bad file must stop the launch before anything is built. */ + /* Parse the Landlock policy file (docs/design/zone-policy-files.md) now: the zone cannot + * reach the zone directory once pivoted, and a bad file must stop the launch early. */ let fs_rules: Vec = match &zone.landlock { None => Vec::new(), Some(rel) => { @@ -690,9 +657,7 @@ pub fn run_in_zone( .map_err(|e| SpawnError::Setup(format!("zone {:?} landlock policy: {e}", zone.name)))? } }; - /* A new network namespace must hold only loopback, but with tunnel drivers - * built in (SIT is) the kernel adds fallback devices such as sit0 unless - * net.core.fb_tunnels_only_for_init_net is set, which needs root. */ + // A kernel with tunnel drivers built in (a developer host's) adds sit0 and such to every netns. if isolate::namespace_flags(zone) & libc::CLONE_NEWNET != 0 { match netzone::suppress_fallback_tunnels() { Ok(Some(note)) => eprintln!("kryptikd: {note}"), @@ -714,9 +679,8 @@ pub fn run_in_zone( } None => None, }; - /* [limits] need a cgroup, and whether one can be made is found by trying, - * not by uid: a delegated subtree is writable by a user, and root in a - * container may find the hierarchy read-only. */ + /* Whether a cgroup for [limits] can be made is found by trying, not by uid: a user may own a + * delegated subtree, and root in a container may find the hierarchy read-only. */ let wants_limits = zone.memory_max.is_some() || zone.pids_max.is_some() || zone.cpu_max.is_some() || zone.io_max.is_some(); let mut limits: Option = None; @@ -733,7 +697,6 @@ pub fn run_in_zone( if !experimental { return Err(SpawnError::Setup(format!( "zone {:?} asks for guarantees this build does not provide:\n - {}\n\ - Refusing rather than implying a guarantee that does not hold.\n\ Set KRYPTIK_EXPERIMENTAL=1 to run it anyway, without them.", zone.name, unsupported.join("\n - ") @@ -744,8 +707,7 @@ pub fn run_in_zone( } } - /* One instance per zone: two launchers would share a data directory, a - * cgroup and a veth name. claim() is an atomic mkdir; a stale entry is reclaimed. */ + // One launcher per zone: two would share a data directory, a cgroup and a veth name. let entry = registry::claim(&zone.name).map_err(|e| SpawnError::Setup(e.to_string()))?; let id = launch_identity(opts, zone)?; @@ -753,9 +715,8 @@ pub fn run_in_zone( .set_identity(id.uid, id.gid) .map_err(|e| SpawnError::Setup(e.to_string()))?; - /* Mount the unlocked volume before the directory checks, so they see its - * root (the zone identity's since `volume init`). Dropping `opened_volume` - * closes it, so a failed launch never leaves plaintext mounted. */ + /* Mount the volume before the directory checks, so they see its root. Dropping + * `opened_volume` closes it, so a failed launch never leaves plaintext mounted. */ let mut opened_volume: Option = None; if zone.storage == StorageMode::Encrypted && id.privileged { let vol = zone.volume.clone().unwrap_or_else(|| volume::default_volume_path(&zone.name)); @@ -777,8 +738,7 @@ pub fn run_in_zone( opened_volume = Some(o); } - /* A new data directory is given to the zone identity. An existing one is - * never re-owned: check_data_dir refuses one that belongs to someone else. */ + // A new data directory goes to the zone identity; an existing one is never re-owned. let existed = std::path::Path::new(rootfs).exists(); std::fs::create_dir_all(rootfs) .map_err(|e| SpawnError::Setup(format!("{rootfs}: {e}")))?; @@ -801,17 +761,12 @@ pub fn run_in_zone( let broker_fd = broker::listen_at(&broker_path, id.uid, id.gid) .map_err(|e| SpawnError::Setup(format!("broker socket {}: {e}", broker_path.display())))?; - /* The child opens this path itself, after unshare(CLONE_NEWNS) and before it - * takes the zone identity: the registry is root's and 0700 - * (docs/design/zone-registry.md), and a descriptor opened in another mount - * namespace cannot be bind-mounted (EINVAL). */ + /* The child opens this path after unshare(CLONE_NEWNS), while still host root: the registry + * is 0700, and a descriptor from another mount namespace cannot be bind-mounted (EINVAL). */ let broker_path_str = broker_path.display().to_string(); - /* After unshare(CLONE_NEWUSER) the child is host uid 0 with no capability - * the host honours, so it cannot walk the session's 0700 runtime directory. - * A privileged launch bind-mounts the verified socket into the registry - * entry, which uid 0 walks by ownership. Declared after `entry`, so the bind - * is undone before the entry is removed. A developer launch passes the path. */ + /* The unshared child cannot walk the session's 0700 runtime directory, so a root launch binds + * the socket into the root-owned registry entry. Declared after `entry`, so it drops first. */ let staged: Option = match (&opts.wayland_socket, id.privileged) { (Some(p), true) => Some(StagedSocket::stage(p, opts.wayland_inode, entry.dir(), &zone.name)?), _ => None, @@ -846,8 +801,7 @@ pub fn run_in_zone( // Not carried on `ready`: pid 1 is forked only after the id maps exist. let initpid = SyncPipe::new()?; - /* Daemon-launched, the zone's side gets a pipe relayed into our log - * (ZoneOutput); at a terminal it keeps the terminal. */ + // Daemon-launched, the zone writes to a pipe relayed into our log; at a terminal, the terminal. let mut zone_out: Option<(RawFd, RawFd)> = None; if opts.ready_fd.is_some() { let mut p = [0 as RawFd; 2]; @@ -867,9 +821,8 @@ pub fn run_in_zone( if pid == 0 { // --- intermediate ------------------------------------------------------ - /* Before anything on this side prints: nothing from here down holds - * the log. dup2 clears close-on-exec on 1 and 2 only. */ if let Some((r, w)) = zone_out { + // Before anything prints, so nothing from here down holds the log. unsafe { libc::dup2(w, 1); libc::dup2(w, 2); @@ -881,7 +834,7 @@ pub fn run_in_zone( ready.close_read(); mapped.close_write(); initpid.close_read(); - // The parent answers the readiness pipe; a copy here would delay the EOF of a failed launch. + // The parent answers the readiness pipe; a copy here would delay a failed launch's EOF. if let Some(fd) = opts.ready_fd { unsafe { libc::close(fd) }; } @@ -905,15 +858,13 @@ pub fn run_in_zone( initpid.close_write(); install_forwarding(pid, false); - /* Put the child in the zone's cgroup before it may unshare. Only the parent - * writes to the cgroup filesystem; /sys/fs/cgroup is not bound into the zone. - * The handle lives for the whole launch, so an early return cannot leak it. */ + /* Place the child in the zone's cgroup before it may unshare; only the parent writes the + * cgroup filesystem. The handle lives as long as the launch, so no return can leak it. */ let zone_cgroup = match &limits { Some(base) => { let cg = cgroup::Cgroup::create(base, &zone.name, parent_pid).map_err(|e| { SpawnError::Setup(format!( - "[limits]: the zone's cgroup could not be created, so \ - the zone's limits would not be in force: {e}" + "[limits]: could not create the zone's cgroup, so no limit would hold: {e}" )) })?; // io.max names the volume's devices, known only once it is open. @@ -937,7 +888,7 @@ pub fn run_in_zone( cg.attach(pid) .map_err(|e| SpawnError::Setup(format!("cgroup attach: {e}")))?; /* Lets reclaim kill a dead launcher's processes by cgroup.kill; unlike - * a pid, a cgroup cannot be reused. A failure is logged, not fatal. */ + * a pid, a cgroup cannot be reused. */ if let Err(e) = entry.set_cgroup(&cg.path().display().to_string()) { eprintln!("kryptikd: registry: could not record the cgroup of zone {}: {e}", zone.name); } @@ -964,9 +915,8 @@ pub fn run_in_zone( ))); } - /* A root launch plumbs the new, still idle network namespace from outside - * (netzone.rs). A routed zone that fails to attach starts with loopback - * only; a nic zone that fails is stopped, not left half-configured. */ + /* A root launch plumbs the still idle network namespace from outside. A routed zone that + * fails to attach gets loopback only; a nic zone that fails is stopped, not left half-done. */ let mut plumbed = false; if id.privileged && zone.network != crate::zone::NetworkMode::None { match crate::netlink::open_netns_of(pid) { @@ -1022,9 +972,8 @@ pub fn run_in_zone( } } - /* Transfers accept only files on the zone's data mount, looked up through - * pid 1's root at request time (the root is built after pid 1 exists). - * Unknown means every transfer is refused. */ + /* Transfers accept only files on the zone's data mount, found through pid 1's root at request + * time, as it is built after pid 1 exists. Unknown refuses every transfer. */ let zone_name = zone.name.clone(); let home_dev = move || -> Option { let zp = init_pid?; @@ -1058,8 +1007,7 @@ pub fn run_in_zone( } } - /* Removed here rather than by Drop so a failure is reported: EBUSY means - * something in the zone outlived the launcher. */ + // Not left to Drop, so a failure is reported: EBUSY means something outlived the launcher. if let Some(cg) = &zone_cgroup { if let Err(e) = cg.destroy() { eprintln!( @@ -1088,8 +1036,8 @@ pub(crate) fn signalled_by(status: libc::c_int) -> Option { if sig != 0 && sig != 0x7f { Some(sig) } else { None } } -/// The intermediate process: enters the namespaces, becomes root there, and -/// supervises pid 1 of the zone. Returns the exit code to mirror. +/// The intermediate: enters the namespaces, becomes root there and supervises pid 1, whose +/// exit code it returns. fn intermediate_main( zone: &Zone, rootfs: &str, @@ -1116,11 +1064,8 @@ fn intermediate_main( rootfs::ensure_stdio(); - /* The id map is the privilege drop (docs/design/privileged-launch.md), so - * the namespace is created as root: the target kernel refuses - * unshare(CLONE_NEWUSER) without CAP_SYS_ADMIN in the initial namespace. - * Supplementary groups are outside the map and need CAP_SETGID, so they go - * now: fatally when privileged; an unprivileged launch keeps them and says so. */ + /* Supplementary groups are outside the id map: drop them while root holds CAP_SETGID. An + * unprivileged launch cannot, and keeps them (docs/design/privileged-launch.md). */ if id.privileged { if let Err(e) = isolate::drop_supplementary_groups() { bail!("setgroups: {e}"); @@ -1136,8 +1081,7 @@ fn intermediate_main( } } - /* Die with the parent. Armed again after the setresuid below: the kernel - * clears PR_SET_PDEATHSIG when euid or egid changes (commit_creds). The + /* Die with the parent; the setresuid below clears this, so it is armed again there. The * getppid check covers a parent that died before the prctl. */ if let Err(e) = isolate::die_with_parent() { bail!("prctl(PR_SET_PDEATHSIG): {e}"); @@ -1146,9 +1090,8 @@ fn intermediate_main( return 125; } - /* Be placed in the zone's cgroup before the unshare: CLONE_NEWCGROUP roots - * the cgroup namespace at the current cgroup, and a zone rooted elsewhere - * cannot name its own cgroup. */ + /* Wait to be placed in the zone's cgroup: CLONE_NEWCGROUP roots the namespace at the + * current cgroup, and a zone rooted elsewhere cannot name its own. */ if let Err(e) = placed.wait() { bail!("parent did not place the zone in its cgroup: {e}"); } @@ -1161,10 +1104,9 @@ fn intermediate_main( if matches!(e, isolate::IsolateError::Syscall { errno, .. } if errno == libc::EPERM) { if unsafe { libc::geteuid() } != 0 { eprintln!( - "kryptikd[zone {}]: creating a user namespace was refused. This kernel \ - restricts unprivileged user namespaces (CONFIG_USER_NS_UNPRIVILEGED=n, \ - kernel.unprivileged_userns_clone=0, or an LSM policy); kryptikd must be \ - started with CAP_SYS_ADMIN in the initial namespace.", + "kryptikd[zone {}]: this kernel restricts unprivileged user namespaces \ + (CONFIG_USER_NS_UNPRIVILEGED=n, kernel.unprivileged_userns_clone=0, or an \ + LSM policy); start kryptikd with CAP_SYS_ADMIN in the initial namespace", zone.name ); } else { @@ -1178,16 +1120,15 @@ fn intermediate_main( bail!("unshare: {e}"); } - /* The new network namespace must hold only loopback. A privileged launch - * refuses anything else; a developer launch, which cannot set the - * fallback-tunnel sysctl, says what it found. */ + /* The new network namespace must hold only loopback: a root launch refuses anything else, + * and a developer launch, which cannot set the fallback-tunnel sysctl, says what it found. */ if flags & libc::CLONE_NEWNET != 0 { match netzone::devices_besides_lo() { Ok(devs) if !devs.is_empty() => { if id.privileged { bail!( - "the new network namespace is not empty: {} besides loopback (see {}); \ - refusing to start a zone in a namespace that is not loopback-only", + "refusing to start: the new network namespace holds {} besides loopback \ + (see {})", devs.join(", "), netzone::FB_TUNNELS_SYSCTL ); @@ -1208,10 +1149,8 @@ fn intermediate_main( } } - /* Open the broker socket while this process has its own mount namespace (so - * a bind from /proc/self/fd works) and is still host root (so it can walk - * the 0700 registry). O_PATH names the inode for the bind; pid 1 inherits it - * and zone_init's descriptor sweep closes it before the exec. */ + /* Open the broker socket now, in our own mount namespace, where a bind from /proc/self/fd + * works, and as host root, who can walk the registry. pid 1's descriptor sweep closes it. */ let broker_fd_for_zone = { let c = match std::ffi::CString::new(broker_path) { Ok(c) => c, @@ -1224,10 +1163,8 @@ fn intermediate_main( fd }; let broker_in_zone = format!("/proc/self/fd/{broker_fd_for_zone}"); - /* The Wayland socket likewise, walked without following symlinks and - * checked against the verified inode. On a privileged launch the path is - * the StagedSocket bind in the registry entry, which host uid 0 walks by - * ownership. */ + /* The Wayland socket likewise, checked against the verified inode; on a root launch the path + * is the staged bind in the registry entry. */ let wayland_in_zone: Option = match wayland_path { None => None, Some(p) => { @@ -1254,7 +1191,6 @@ fn intermediate_main( }; mapped.close_read(); - // Become root in the new user namespace. if unsafe { libc::setresuid(0, 0, 0) } < 0 { bail!("setresuid: {}", io::Error::last_os_error()); } @@ -1262,7 +1198,7 @@ fn intermediate_main( bail!("setresgid: {}", io::Error::last_os_error()); } - // Re-arm PR_SET_PDEATHSIG, which the credential change just cleared. + // Re-arm PR_SET_PDEATHSIG, which the credential change cleared. if let Err(e) = isolate::die_with_parent() { bail!("prctl(PR_SET_PDEATHSIG) after the id switch: {e}"); } @@ -1271,10 +1207,8 @@ fn intermediate_main( return 125; } - /* A core-scheduling cookie of the zone's own, inherited by everything it - * runs, so a core's sibling threads never run another zone's tasks (ADR-011). - * A refusal is judged by the machine, not the errno: with no SMT online it - * is fine, without kernel support it is noted, with siblings online fatal. */ + /* The zone's own core-scheduling cookie, so a core's siblings never run another zone's tasks + * (ADR-011). A refusal is fatal only with sibling threads online. */ if let Err(e) = isolate::take_core_cookie() { match isolate::core_scheduling() { isolate::CoreSched::NoSmt => {} @@ -1292,9 +1226,8 @@ fn intermediate_main( bail!("sethostname: {e}"); } - /* pid 1 cannot check its parent as this process checked its own: across - * the pid namespace getppid() is 0. It watches this pipe instead, whose - * write end only this process holds, and which closes when it dies. */ + /* Across the pid namespace pid 1's getppid() is 0, so it watches this pipe instead: only this + * process holds the write end, which closes when it dies. */ let alive = match SyncPipe::new() { Ok(p) => p, Err(e) => bail!("{e}"), @@ -1354,16 +1287,14 @@ fn zone_init( if let Err(e) = isolate::die_with_parent() { bail!("prctl(PR_SET_PDEATHSIG): {e}"); } - /* An intermediate that died before the prctl never sends the signal. A - * dying process closes its files before it signals its children, so no - * hang-up here means the signal is still to come. */ + /* An intermediate that died before the prctl never sends the signal. A dying process closes + * its files before it signals its children, so no hang-up means the signal is still to come. */ if alive.peer_gone() { return 125; } alive.close_read(); - /* A root holding only what this zone should see: the containment boundary. - * Everything after it is defense in depth. */ + // The pivoted root is the containment boundary; everything after it is defense in depth. let ephemeral = match zone.storage { StorageMode::Ephemeral => zone.size.as_deref(), // None: the data directory is the home, encrypted or not. @@ -1386,14 +1317,12 @@ fn zone_init( } } - /* Landlock over the pivoted tree: writes only where landlock::zone_rules - * allow, so read-only mounts are denied twice. A plumbed nic zone also - * writes its /run and /var/lib (nic_zone_rules); the condition must match - * pivot_into's, or a rule on a missing path would refuse the zone. */ + /* Writes only where landlock::zone_rules allow, so read-only mounts are denied twice. The nic + * condition must match pivot_into's, or a rule on a missing path would refuse the zone. */ if let Err(e) = landlock::confine_pivoted_zone(&home, resolver == rootfs::Resolver::Writable) { bail!("landlock: {e}"); } - // The zone's policy file is a second layer; the kernel intersects layers, so it can only narrow. + // The zone's policy file is a second layer; layers intersect, so it can only narrow. if !fs_rules.is_empty() { if let Err(e) = landlock::confine_further(fs_rules) { bail!("landlock policy: {e}"); @@ -1405,8 +1334,7 @@ fn zone_init( bail!("close_range: {e}"); } - /* Rebuild the environment from the allowlist. Every variable is removed; - * walked as OsStrings because std::env::vars() panics on non-UTF-8. */ + // Rebuild the environment; walked as OsStrings since std::env::vars() panics on non-UTF-8. let all: Vec<(std::ffi::OsString, std::ffi::OsString)> = std::env::vars_os().collect(); let caller: Vec<(String, String)> = all .iter() @@ -1420,9 +1348,8 @@ fn zone_init( std::env::set_var(k, v); } - /* Drop the bounding set after the privileged steps (the mounts and - * pivot_root need CAP_SYS_ADMIN) and before exec, so the command cannot - * regain a capability, even through a file capability. Fatal on failure. */ + /* The bounding set goes after the mounts, which need CAP_SYS_ADMIN, and before exec, so not + * even a file capability can regain one. */ let keep: &[libc::c_int] = zone_policy.map(|p| p.keep_caps.as_slice()).unwrap_or(&[]); if let Err(e) = caps::drop_bounding_set_except(keep) { bail!("could not drop the capability bounding set: {e}"); @@ -1469,17 +1396,15 @@ pub const ENV_PASSTHROUGH: &[&str] = &[ "LC_ADDRESS", "LC_TELEPHONE", "LC_MEASUREMENT", "LC_IDENTIFICATION", ]; -/// A value fit for a locale or terminal name: short, with no shell or path -/// metacharacters. Anything else is dropped, not sanitized. +/// Fit for a locale or terminal name: short, no shell or path metacharacters; else dropped. pub fn env_value_is_sane(v: &str) -> bool { !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || "._+:@-".contains(c)) } -/// The complete environment the zone's command starts with. With a display, -/// WAYLAND_DISPLAY is the socket's absolute path (libwayland needs no -/// XDG_RUNTIME_DIR then) and XDG_RUNTIME_DIR is the zone's /tmp. +/// The zone command's whole environment. With a display, WAYLAND_DISPLAY is the socket's +/// absolute path (libwayland then needs no XDG_RUNTIME_DIR) and XDG_RUNTIME_DIR is /tmp. pub fn zone_environment(zone: &Zone, home: &str, caller: &[(String, String)], wayland: bool) -> Vec<(String, String)> { let mut env: Vec<(String, String)> = vec![ ("PATH".into(), "/usr/bin:/usr/sbin:/bin:/sbin".into()), @@ -1547,10 +1472,10 @@ pub fn explain(zone: &Zone, rootfs: &str, zones_dir: &std::path::Path) -> String let storage = match zone.storage { StorageMode::Encrypted => format!( "encrypted: $HOME is the ext4 inside the LUKS2 container {},\n\ - \x20 opened to {} on a root launch (passphrase from\n\ - \x20 --passphrase-file), mounted nosuid,nodev at {}, unmounted\n\ - \x20 and closed when the zone exits (the key leaves the kernel).\n\ - \x20 Unprivileged launches are refused: they cannot open it.", + \x20 opened to {} on a root launch (passphrase from the trusted\n\ + \x20 prompt or --passphrase-file), mounted nosuid,nodev at {},\n\ + \x20 unmounted and closed when the zone exits (the key leaves the\n\ + \x20 kernel). Unprivileged launches are refused: they cannot open it.", zone.volume.as_deref().unwrap_or("?"), volume::mapper_path(&zone.name), rootfs @@ -1566,8 +1491,7 @@ pub fn explain(zone: &Zone, rootfs: &str, zones_dir: &std::path::Path) -> String StorageMode::Ephemeral => format!( "ephemeral: $HOME is a per-launch tmpfs of {}, freed when the zone exits.\n\ \x20 Nothing the zone writes reaches its persistent directory.\n\ - \x20 CAVEAT: tmpfs pages can be written to swap. Until Kryptik\n\ - \x20 ships with encrypted or no swap this is NOT secure erasure.", + \x20 tmpfs pages can be written to swap, so this is NOT secure erasure.", zone.size.as_deref().unwrap_or("?") ), }; @@ -1595,17 +1519,11 @@ pub fn explain(zone: &Zone, rootfs: &str, zones_dir: &std::path::Path) -> String } }; - let mut rules: Vec = landlock::zone_rules(&home) - .iter() - .map(|r| format!("{:<12} {}", r.path, landlock::describe_access(r.access))) - .collect(); + let rule_line = |r: &landlock::ZoneRule| format!("{:<12} {}", r.path, landlock::describe_access(r.access)); + let mut rules: Vec = landlock::zone_rules(&home).iter().map(rule_line).collect(); if zone.network == crate::zone::NetworkMode::Nic { rules.push("-- and, once it holds the NIC, its own tmpfs for the network stack's state:".to_string()); - rules.extend( - landlock::nic_zone_rules() - .iter() - .map(|r| format!("{:<12} {}", r.path, landlock::describe_access(r.access))), - ); + rules.extend(landlock::nic_zone_rules().iter().map(rule_line)); } if let Some(rel) = &zone.landlock { let path = policy::resolve(zones_dir, rel); @@ -1615,11 +1533,7 @@ pub fn explain(zone: &Zone, rootfs: &str, zones_dir: &std::path::Path) -> String { Ok(extra) => { rules.push(format!("-- and then narrowed by {rel}, which grants only:")); - rules.extend( - extra - .iter() - .map(|r| format!("{:<12} {}", r.path, landlock::describe_access(r.access))), - ); + rules.extend(extra.iter().map(rule_line)); } Err(e) => rules.push(format!("-- {rel}: ERROR - {e} (the zone will not start)")), } diff --git a/compartments/kryptikd/src/spawn/tests.rs b/compartments/kryptikd/src/spawn/tests.rs index e96fb63b..19501387 100644 --- a/compartments/kryptikd/src/spawn/tests.rs +++ b/compartments/kryptikd/src/spawn/tests.rs @@ -13,7 +13,7 @@ fn relayed(chunks: &[&[u8]]) -> Vec { } #[test] -fn zone_output_is_prefixed_and_sanitized() { +fn zone_output_relay() { // Every line is marked, and a line split across reads stays one line. assert_eq!( relayed(&[b"kryptikd[zone work]: broker served \"steal\"\nhal", b"f\n"]), @@ -54,7 +54,7 @@ fn broker_log_bounded() { } #[test] -fn wayland_socket_is_the_verified_one() { +fn wayland_socket_verified() { let dir = std::env::temp_dir().join(format!("kryptik-wlsock-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -85,8 +85,7 @@ fn peer_gone_after_close() { #[test] fn pump_reads_bounded_amount() { - /* A zone that never stops writing must not keep one pump from - * returning: fill the pipe past one pass and check the rest is left. */ + // An endless writer must not keep a pump from returning: fill past one pass, check the rest. let mut fds = [0; 2]; assert_eq!(unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) }, 0); let (r, w) = (fds[0], fds[1]); @@ -139,10 +138,9 @@ fn explain_names_core_scheduling() { assert!(line.contains(want), "{line}"); } -/// Two forked writers share one pipe, one of them through `log_line`; -/// every line read back must be one writer's, whole. +/// Two forked writers share a pipe, one through `log_line`; every line must come back whole. #[test] -fn log_line_is_never_split() { +fn log_line_never_split() { use std::io::Read; use std::os::unix::io::FromRawFd; const N: usize = 1500; @@ -194,7 +192,7 @@ fn log_line_is_never_split() { } #[test] -fn explain_reports_capabilities_kept_by_policy() { +fn explain_policy_caps() { let dir = std::env::temp_dir().join(format!("kryptik-explain-{}", std::process::id())); std::fs::create_dir_all(dir.join("policy")).unwrap(); std::fs::write(dir.join("policy/n.seccomp"), "keep-capability CAP_NET_RAW\nkeep-capability CAP_NET_ADMIN\n").unwrap(); @@ -243,7 +241,6 @@ fn explain_warns_persistent_unencrypted() { e.contains("/var/lib/kryptik/zones/t (visible inside as"), "the data line must place it: {e}" ); - assert!(!e.contains("NOT YET IMPLEMENTED"), "persistent IS implemented: {e}"); } fn z_encrypted() -> Zone { @@ -256,29 +253,29 @@ fn z_encrypted() -> Zone { } #[test] -fn rootfs_path_is_under_base() { +fn rootfs_under_base() { assert_eq!(zone_rootfs(&z("routed"), "/var/lib/kryptik/zones"), "/var/lib/kryptik/zones/t"); } #[test] -fn explain_names_namespaces_and_storage() { +fn explain_namespaces_storage() { let e = explain(&z("none"), "/tmp/t", std::path::Path::new("/nonexistent")); assert!(e.contains("user"), "{e}"); assert!(e.contains("net"), "{e}"); - assert!(e.contains("tmpfs"), "explain must say what ephemeral storage IS: {e}"); + assert!(e.contains("tmpfs"), "explain must say what ephemeral storage is: {e}"); } #[test] fn explain_does_not_overclaim() { let e = explain(&z("routed"), "/tmp/t", std::path::Path::new("/tmp")); // A routed zone's way out is the nic zone's to open, never kryptikd's. - let honest = e.contains("no path out") + let says_who = e.contains("no path out") || e.contains("loopback") || e.contains("nic zone") || e.contains("NAT") || e.contains("uid_base"); - assert!(honest, "explain must say who opens a routed zone's way out: {e}"); + assert!(says_who, "explain must say who opens a routed zone's way out: {e}"); assert!(e.contains("swap"), "explain must name the swap caveat: {e}"); assert!( e.contains("NOT secure erasure"), @@ -331,7 +328,7 @@ fn environment_is_allowlist() { } #[test] -fn passthrough_values_must_be_well_formed() { +fn passthrough_well_formed() { assert!(env_value_is_sane("xterm-256color")); assert!(env_value_is_sane("en_US.UTF-8")); assert!(!env_value_is_sane("")); @@ -348,7 +345,7 @@ fn passthrough_values_must_be_well_formed() { } #[test] -fn unprivileged_launch_cannot_pick_identity() { +fn unprivileged_identity() { if unsafe { libc::geteuid() } == 0 { // As root the rule is the reverse; see the next test. return; @@ -361,7 +358,7 @@ fn unprivileged_launch_cannot_pick_identity() { } #[test] -fn root_launch_needs_unprivileged_identity() { +fn root_launch_identity() { if unsafe { libc::geteuid() } != 0 { return; } diff --git a/compartments/kryptikd/src/volume.rs b/compartments/kryptikd/src/volume.rs index a1736154..534d2283 100644 --- a/compartments/kryptikd/src/volume.rs +++ b/compartments/kryptikd/src/volume.rs @@ -1,11 +1,6 @@ -//! Per-zone LUKS2 volumes (docs/design/encrypted-volumes.md), opened in zone 0 -//! before the zone exists and closed after it is gone. -//! -//! The container opens as `/dev/mapper/kryptik-zone-`, a prefix only zone -//! volumes use, which is how `gc` tells them from the state partition. The -//! ext4 inside is mounted `nosuid,nodev` as the zone's data directory; the -//! zone never sees the container or the mapping. Passphrases reach cryptsetup -//! on stdin or a memfd, never argv, and no tool runs through a shell. +//! Per-zone LUKS2 volumes (docs/design/encrypted-volumes.md), opened in zone 0 before the zone +//! exists and closed after it is gone; the zone sees only the ext4 inside, mounted `nosuid,nodev`. +//! Passphrases go on stdin or in a memfd, never argv, and no tool runs through a shell. use std::ffi::CString; use std::fs; @@ -57,8 +52,7 @@ impl Passphrase { pub fn as_bytes(&self) -> &[u8] { &self.0 } - /// Read a passphrase from a regular file owned by root or the caller, with - /// no group or other permission bits. + /// Read from a regular file owned by root or the caller, with no group or other access. pub fn from_file(path: &Path) -> Result { /* Check the opened inode, not the path, which could be swapped. NONBLOCK * keeps a planted FIFO from hanging open() before the type check. */ @@ -82,11 +76,8 @@ impl Passphrase { } Self::read_bounded(f) } -} -impl Passphrase { - /// Read a passphrase from a memfd or pipe received over SCM_RIGHTS. Always - /// closes `fd`; a pipe must reach EOF within 5 s. + /// Read from a memfd or pipe passed over SCM_RIGHTS, closing `fd`; a pipe must end within 5 s. pub fn from_fd(fd: i32) -> Result { let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; if flags < 0 { @@ -209,8 +200,7 @@ pub fn mapping_exists(zone: &str) -> bool { Path::new(&mapper_path(zone)).exists() } -/// An open, mounted volume. Dropping it unclosed closes it, so a failed launch -/// never leaves plaintext mounted. +/// An open, mounted volume, closed on drop so a failed launch never leaves plaintext mounted. pub struct Opened { pub zone: String, pub mountpoint: String, @@ -265,13 +255,12 @@ pub fn open_and_mount(zone: &str, volume: &str, pass: &Passphrase, mountpoint: & Ok(opened) } -/// Unmount and close; dm-crypt then frees the volume key. A busy unmount is -/// retried for 3 s, as a dead zone's mount namespace goes asynchronously; -/// after that something escaped, which is an invariant failure. +/// Unmount and close; dm-crypt then frees the volume key. pub fn close_mapping(zone: &str, mountpoint: &str) -> Result<(), VolumeError> { let mapper = mapper_path(zone); let mut last = String::new(); let mut unmounted = !is_mountpoint(mountpoint); + // A dead zone's mount namespace goes asynchronously; still busy after 3 s, something escaped. for _ in 0..30 { if unmounted { break; @@ -303,8 +292,7 @@ pub fn is_mountpoint(path: &str) -> bool { text.lines().any(|l| l.split_whitespace().nth(1) == Some(&esc)) } -/// Create a volume: LUKS2 (argon2id) on a sparse file or empty block device, -/// holding an ext4 owned by the zone's identity. Refuses anything with a signature. +/// Create LUKS2 (argon2id) on a sparse file or blank device, holding an ext4 owned by the zone. pub fn init(zone: &str, volume: &str, size: u64, pass: &Passphrase, uid: u32, gid: u32) -> Result<(), VolumeError> { let p = Path::new(volume); if p.exists() { @@ -404,10 +392,7 @@ pub fn restore_header(volume: &str, from: &str) -> Result<(), VolumeError> { Ok(()) } -/// Erase a zone's key slots, then delete its container file: the zone is -/// then as before `volume init`. Refused while the mapping is open (the zone -/// runs, or `gc` has not closed it), for a block device (it is wiped by hand, -/// not unlinked) and for a file without a LUKS signature. +/// Erase a zone's key slots, then delete its container file: back to before `volume init`. pub fn destroy(zone: &str, volume: &str) -> Result<(), VolumeError> { if mapping_exists(zone) { return Err(VolumeError::Io(format!( @@ -433,9 +418,7 @@ pub fn destroy(zone: &str, volume: &str) -> Result<(), VolumeError> { if sig.is_empty() { "no signature".to_string() } else { format!("a {sig} signature") } ))); } - // The slots first: an unlinked file's blocks stay on the disk until they - // are reused, and without its slots the container is ciphertext under a - // key nothing holds. + // Slots first: an unlinked file's blocks linger, but without slots no key decrypts them. run("cryptsetup luksErase", "cryptsetup", &["luksErase", "--batch-mode", volume], None) .map_err(|e| tool_err("cryptsetup luksErase", e))?; fs::remove_file(p).map_err(|e| VolumeError::Io(format!("{volume}: {e}"))) diff --git a/compartments/kryptikd/src/wifi.rs b/compartments/kryptikd/src/wifi.rs index a4213426..637dc74b 100644 --- a/compartments/kryptikd/src/wifi.rs +++ b/compartments/kryptikd/src/wifi.rs @@ -1,8 +1,5 @@ -//! Wi-Fi credentials for the net zone (docs/design/net-zone.md). -//! -//! kryptikd alone writes the supplicant file in zone 0: 0400, owned by the nic -//! zone's identity (the zone's root is that host uid). The zone gets it -//! read-only at `/etc/wpa_supplicant.conf` on its next start. +//! Wi-Fi credentials for the net zone (docs/design/net-zone.md). kryptikd alone writes the file, +//! 0400 and owned by the host uid of the nic zone's root; the zone gets it read-only on next start. use std::fs; use std::io::{self, Read, Write}; @@ -61,8 +58,7 @@ pub fn check_ssid(ssid: &str) -> Result<(), String> { Ok(()) } -/// 8 to 63 printable ASCII characters without quote or backslash, or 64 hex -/// digits for a raw PSK. Error messages never repeat the passphrase. +/// 8 to 63 printable ASCII without quote or backslash, or 64 hex digits; errors never echo it. pub fn check_passphrase(pass: &str) -> Result { if pass.len() == 64 && pass.bytes().all(|b| b.is_ascii_hexdigit()) { return Ok(Psk::Hex(pass.to_string())); @@ -99,9 +95,8 @@ pub fn render(nets: &[Network]) -> String { out } -/// Read back what `render` writes and refuse anything else: rewriting a -/// hand-edited file would lose the edit or keep unchecked lines. Errors name -/// the line, never its content. +/// Read back what `render` writes and refuse anything else, which a rewrite would lose or keep +/// unchecked. Errors name the line, never its content. pub fn parse(text: &str) -> Result, String> { let mut nets = Vec::new(); let mut block: Option<(Option, Option)> = None; @@ -180,8 +175,7 @@ pub fn list(dir: &Path) -> Result, String> { Ok(load(dir)?.into_iter().map(|n| n.ssid).collect()) } -/// The file's owner on a root run: the nic zone's identity. `None` keeps the -/// writer's, when unprivileged or when the nic zone declares no identity. +/// The nic zone's identity, to own the file on a root run; `None` keeps the writer's. pub fn owner_for(zones_dir: &Path) -> Result, String> { if unsafe { libc::geteuid() } != 0 { return Ok(None); @@ -226,8 +220,7 @@ pub fn forget(dir: &Path, owner: Option<(u32, u32)>, ssid: &str) -> Result<(), S write_atomic(dir, &render(&nets), owner) } -/// Create the directory 0711 if missing, so the zone's identity can reach -/// the file but nobody else can list the directory. +/// Create the directory 0711 if missing: the zone's identity reaches the file, nobody lists it. fn ensure_dir(dir: &Path) -> Result<(), String> { match fs::symlink_metadata(dir) { Ok(md) if md.is_dir() => Ok(()), @@ -256,8 +249,7 @@ fn write_atomic(dir: &Path, contents: &str, owner: Option<(u32, u32)>) -> Result crate::files::write_atomic(&path, &[contents.as_bytes()], 0o400, owner).map_err(|e| format!("{}: {e}", path.display())) } -/// Restart the net zone so it reads the new file; returns what happened and -/// never fails. Only for the directory the service reads, so tests leave it be. +/// Restart the net zone on the new file and say how it went; other directories (tests) skip this. pub fn restart_net_zone(dir: &Path) -> String { if dir != Path::new(DEFAULT_DIR) { return format!( @@ -281,8 +273,7 @@ pub fn restart_net_zone(dir: &Path) -> String { } } -/// Read one line from stdin for `kryptikd wifi add`, prompting with echo off -/// on a terminal. The passphrase never comes from argv or the environment. +/// Read the passphrase from stdin, echo off on a terminal; never from argv or the environment. pub fn read_passphrase(prompt: &str) -> Result { let tty = unsafe { libc::isatty(0) } == 1; let mut saved: libc::termios = unsafe { std::mem::zeroed() }; diff --git a/compartments/kryptikd/src/wifi/tests.rs b/compartments/kryptikd/src/wifi/tests.rs index c2fed64b..5de0c89a 100644 --- a/compartments/kryptikd/src/wifi/tests.rs +++ b/compartments/kryptikd/src/wifi/tests.rs @@ -120,7 +120,7 @@ fn write_is_atomic_rename() { let dir = tmpdir("atomic"); add(&dir, None, "Home", "long enough").unwrap(); let ino = fs::metadata(conf_path(&dir)).unwrap().ino(); - // The temporary of a writer that died before its rename goes. + // A temporary left by a writer that died before its rename is removed. fs::write(dir.join(format!(".{FILE_NAME}.4294967295")), "psk=\"secret\"").unwrap(); add(&dir, None, "Other", "long enough").unwrap(); assert_ne!(fs::metadata(conf_path(&dir)).unwrap().ino(), ino, "a new inode replaced the old file"); From a1d9ad4ecdf3fd1e846c1a573c4373c82e8745e4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 01:57:10 -0700 Subject: [PATCH 03/14] Shorten compositor comments and test names Comment stacks in kryptik-wlproxy and zoneid become one line, or a two-line block where the reason needs it, and long test names are cut to a few words. Code changes: the wlproxy session tests share two setup helpers, and zoneid's error and audit messages lose their essays. The generated protocol tables and zone-colours.h are unchanged. --- compositor/Cargo.toml | 4 +- compositor/wlproxy/Cargo.toml | 3 +- compositor/wlproxy/src/main.rs | 31 ++----- compositor/wlproxy/src/policy.rs | 31 +++---- compositor/wlproxy/src/policy/tests.rs | 7 +- compositor/wlproxy/src/protocol.rs | 19 ++-- compositor/wlproxy/src/protocol/tests.rs | 8 +- compositor/wlproxy/src/session.rs | 69 ++++++--------- compositor/wlproxy/src/session/tests.rs | 105 ++++++++++------------- compositor/wlproxy/src/wire.rs | 21 ++--- compositor/wlproxy/src/wire/tests.rs | 2 +- compositor/wlproxy/tests/live.rs | 22 ++--- compositor/zoneid/Cargo.toml | 3 +- compositor/zoneid/src/bin/zoneid.rs | 36 +++----- compositor/zoneid/src/color.rs | 19 ++-- compositor/zoneid/src/color/tests.rs | 8 +- compositor/zoneid/src/cvd.rs | 9 +- compositor/zoneid/src/cvd/tests.rs | 5 +- compositor/zoneid/src/distinct.rs | 24 ++---- compositor/zoneid/src/distinct/tests.rs | 19 ++-- compositor/zoneid/src/identity.rs | 38 +++----- compositor/zoneid/src/identity/tests.rs | 13 ++- compositor/zoneid/src/lib.rs | 12 +-- compositor/zoneid/src/palette.rs | 46 ++++------ compositor/zoneid/src/palette/tests.rs | 5 +- compositor/zoneid/src/toml.rs | 24 ++---- compositor/zoneid/src/toml/tests.rs | 10 +-- compositor/zoneid/src/zones.rs | 8 +- compositor/zoneid/src/zones/tests.rs | 5 +- tools/desktop/gen-wl-protocol.py | 5 +- 30 files changed, 221 insertions(+), 390 deletions(-) diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 8080daa0..9970548d 100755 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -1,6 +1,4 @@ -# Kryptik compositor layer (docs/roadmap.md, Compositor and GUI isolation). -# A workspace apart from compartments/kryptikd, so no desktop dependency can -# reach the privileged daemon's lockfile (ADR-010). +# Apart from kryptikd's workspace, so no desktop dependency reaches its lockfile (ADR-010). [workspace] resolver = "2" members = ["zoneid", "wlproxy"] diff --git a/compositor/wlproxy/Cargo.toml b/compositor/wlproxy/Cargo.toml index d70487c8..e29d45e2 100755 --- a/compositor/wlproxy/Cargo.toml +++ b/compositor/wlproxy/Cargo.toml @@ -5,8 +5,7 @@ version.workspace = true edition.workspace = true license.workspace = true -# Dependency policy (ADR-010): this parses an untrusted zone's bytes, so libc -# only. The wire format is hand-rolled; Wayland libraries are built to be permissive. +# libc only (ADR-010): this parses a zone's bytes, and Wayland libraries are built to be permissive. [dependencies] libc = "0.2" diff --git a/compositor/wlproxy/src/main.rs b/compositor/wlproxy/src/main.rs index e4c4833c..a55e71b6 100755 --- a/compositor/wlproxy/src/main.rs +++ b/compositor/wlproxy/src/main.rs @@ -1,12 +1,8 @@ -//! kryptik-wlproxy: the per-zone Wayland filtering proxy. +//! kryptik-wlproxy: the per-zone Wayland filtering proxy, single-threaded and unprivileged. //! //! kryptik-wlproxy --zone NAME --listen PATH --upstream PATH [--max-clients N] [--once] //! -//! Each client on PATH (bound into the zone as /run/kryptik/wayland-0) gets a -//! Session to the compositor at --upstream (session.rs, policy.rs). A client -//! that breaks the protocol or reaches for something hidden gets a -//! wl_display.error and nothing more is forwarded. Single-threaded and -//! unprivileged; what a client can make it allocate is bounded. +//! Each client of PATH (the zone's /run/kryptik/wayland-0) gets its own Session to --upstream. mod policy; mod protocol; @@ -21,9 +17,7 @@ use std::time::{Duration, Instant}; use session::{Dir, Session}; -/// The log is a file in /run, which is RAM, and a client can make lines at -/// will: they are rate-limited and cut, the whole is bounded, and a failed -/// write is ignored rather than fatal. +/// Bounded log: the file is in /run, which is RAM, and a client can make lines at will. struct Log { zone: String, since: Instant, lines: u32, dropped: u32, left: usize } impl Log { const PER_SECOND: u32 = 20; @@ -118,14 +112,13 @@ const FDS_PER_SESSION: usize = 2 + 2 * policy::MAX_PENDING_FDS; /// stdio, the listener and room to spare. const FDS_RESERVED: usize = 8; -/// Clients whose descriptors fit under `limit`, and never none. +/// How many clients' descriptors fit under `limit`; at least one. fn clients_within(limit: u64, wanted: usize) -> usize { let room = usize::try_from(limit).unwrap_or(usize::MAX).saturating_sub(FDS_RESERVED) / FDS_PER_SESSION; wanted.min(room).max(1) } -/// Raise the descriptor limit to the hard one and fit max_clients under it: -/// past the limit, accept and recvmsg fail for every client of the zone. +/// Raise the fd limit to the hard one and fit max_clients under it; past it, accept and recvmsg fail. fn fit_descriptors(o: &mut Opts) { let mut rl = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rl) } != 0 { @@ -168,8 +161,7 @@ fn main() { std::process::exit(1); } }; - /* The zone's uid must be able to connect through the bind mount; the - * directory on this side keeps everyone else out. */ + // 0666 so the zone's uid can connect through the bind mount; the directory keeps others out. let _ = std::fs::set_permissions(&o.listen, std::os::unix::fs::PermissionsExt::from_mode(0o666)); listener.set_nonblocking(true).expect("nonblocking listener"); eprintln!("kryptik-wlproxy[{}]: listening on {} -> {}", o.zone, o.listen.display(), o.upstream.display()); @@ -178,15 +170,11 @@ fn main() { let mut next_id = 1u64; let mut served = 0u64; let mut log = Log::new(&o.zone); - /* After a failed accept the listener rests until this passes: the failed - * connection is still pending, so polling again at once would spin (and a - * client can exhaust descriptors to cause that). */ + // A failed accept leaves the connection pending, so the listener rests or poll would spin. let mut accept_after = Instant::now(); let mut fds: Vec = Vec::new(); loop { - /* The poll set snapshots `sessions`: the listener, then session i at - * 1+2i and 2+2i. `sessions` must not change while entries are read, so - * accepting comes last and the service loop stops at `polled`. */ + // fds: the listener, then session i at 1+2i and 2+2i; valid until `sessions` changes. let polled = sessions.len(); fds.clear(); let pause = accept_after.saturating_duration_since(Instant::now()); @@ -211,7 +199,6 @@ fn main() { eprintln!("kryptik-wlproxy[{}]: poll: {e}", o.zone); std::process::exit(1); } - // Service the sessions that were polled, against their own entries. let mut closed: Vec = Vec::new(); let mut zone_pool_bytes: usize = sessions.iter().map(|l| l.s.shm_pool_bytes).sum(); let mut zone_pool_count: usize = sessions.iter().map(|l| l.s.shm_pool_count).sum(); @@ -273,7 +260,7 @@ fn main() { return; } } - // Accept last, when nothing indexes the snapshot any more. + // Accept last, once nothing indexes `fds` by session. if fds[0].revents & libc::POLLIN != 0 { match listener.accept() { Ok((client, _)) => match UnixStream::connect(&o.upstream) { diff --git a/compositor/wlproxy/src/policy.rs b/compositor/wlproxy/src/policy.rs index 3d629de1..7fb8bb15 100644 --- a/compositor/wlproxy/src/policy.rs +++ b/compositor/wlproxy/src/policy.rs @@ -1,13 +1,9 @@ -//! What a zone's client may reach through the proxy, and what it rewrites. -//! -//! Globals off the allowlist are never advertised, so they cannot be bound: no -//! capture, global input, layer shell (nothing may draw over the chrome), -//! clipboard (the broker is the only cross-zone channel), virtual keyboard, -//! output management or activation; binding one anyway disconnects the client. -//! Every toplevel title and app_id comes out carrying the zone's name. +//! What a zone's client may bind, and how its titles and app_ids carry the zone's name. +//! Nothing else is advertised: no capture, global input, layer shell (nothing may draw over the +//! chrome), clipboard (the broker is the only cross-zone channel), virtual keyboard, output +//! management or activation. -/// Interfaces a zone client may bind, capped at the version the generated -/// tables know: requests of a newer version could not be parsed here. +/// Bindable interfaces, capped at the tables' versions: a newer request could not be parsed. pub const ALLOWED: &[(&str, u32)] = &[ ("wl_compositor", 6), ("wl_subcompositor", 1), @@ -26,11 +22,9 @@ pub fn allowed_version(interface: &str) -> Option { /// The most bytes a rewritten title may carry; only one line is ever shown. pub const MAX_TITLE_BYTES: usize = 256; -/// Prefix a title with `[zone] `. A claimed `[vault] ` just follows the real -/// prefix: `[work] [vault] ...`. +/// Prefix a title with `[zone] `; a claimed `[vault] ` follows the real one: `[work] [vault] ...`. pub fn title_for(zone: &str, title: &str) -> String { - /* Titles reach dwl's line-based status stream and terminal chrome: no - * control may inject records, terminal escapes or bidi overrides. */ + // Titles reach dwl's status lines and terminal chrome: controls and bidi marks become spaces. let title: String = title.chars().map(|c| { if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') { ' ' } else { c } }).collect(); @@ -39,8 +33,7 @@ pub fn title_for(zone: &str, title: &str) -> String { out } -/// Cut `s` to at most `max` bytes, ending in `...` if anything was cut. The -/// cut lands on a character boundary: `String::truncate` panics inside one. +/// Cut `s` to `max` bytes with a `...`, on a char boundary: `String::truncate` panics inside one. fn bound_utf8(s: &mut String, max: usize) { if s.len() <= max { return; @@ -53,8 +46,7 @@ fn bound_utf8(s: &mut String, max: usize) { s.push_str("..."); } -/// `kryptik..`, the claimed part cut to a safe alphabet so a -/// zone name inside it cannot pass for the real one. +/// `kryptik..`, the claim cut to `[A-Za-z0-9_-]` so it cannot pass for another zone. pub fn app_id_for(zone: &str, claimed: &str) -> String { let cleaned: String = claimed .chars() @@ -64,10 +56,9 @@ pub fn app_id_for(zone: &str, claimed: &str) -> String { format!("kryptik.{zone}.{}", if cleaned.is_empty() { "app".to_string() } else { cleaned }) } -/// Resource bounds per client connection and across a zone's connections. +// Resource bounds per client connection and across a zone's connections. pub const MAX_OBJECTS: usize = 4096; -/// Id slots per range. libwayland reuses freed ids, so its slots never outnumber -/// its peak of live objects; a client that never reuses one stops here. +/// Id slots per range: libwayland reuses freed ids, so only a client that never does reaches this. pub const MAX_ID_SLOTS: usize = 2 * MAX_OBJECTS; pub const MAX_PENDING_BYTES: usize = 1 << 20; // per direction pub const MAX_PENDING_FDS: usize = 64; diff --git a/compositor/wlproxy/src/policy/tests.rs b/compositor/wlproxy/src/policy/tests.rs index 69c0bd20..81f04fef 100644 --- a/compositor/wlproxy/src/policy/tests.rs +++ b/compositor/wlproxy/src/policy/tests.rs @@ -22,8 +22,7 @@ fn capture_and_clipboard_are_hidden() { } } -/// Objects are forgotten only on delete_id, which names client-created ids, -/// so no event a zone can reach may create one. +/// Only delete_id frees objects, and only client-created ones: no reachable event may create one. #[test] fn no_compositor_created_objects() { use crate::protocol::{find, Arg}; @@ -76,7 +75,7 @@ fn title_controls_are_replaced() { /// The bound is in bytes; the cut must still land on a character boundary. #[test] -fn multibyte_title_cut_on_char_boundary() { +fn title_cut_on_char_boundary() { // 200 x U+00E9 is 400 bytes; byte 253 is inside a character. let t = title_for("vault", &"\u{00e9}".repeat(200)); assert!(t.len() <= MAX_TITLE_BYTES, "{}", t.len()); @@ -92,7 +91,7 @@ fn multibyte_title_cut_on_char_boundary() { assert!(t.ends_with("...")); assert!(t.trim_end_matches("...").chars().skip(4).all(|c| c == '\u{1F600}'), "{t:?}"); - // Mixed widths: an accented character exactly straddling the cut. + // Mixed widths: an accented character straddling the cut. let mut title = "x".repeat(MAX_TITLE_BYTES - 3 - "[work] ".len() - 1); title.push('\u{00e9}'); title.push_str("tail"); diff --git a/compositor/wlproxy/src/protocol.rs b/compositor/wlproxy/src/protocol.rs index 12a0091d..3fa56690 100644 --- a/compositor/wlproxy/src/protocol.rs +++ b/compositor/wlproxy/src/protocol.rs @@ -1,6 +1,4 @@ -//! Message signatures from the generated tables (protocol_tables.rs): how many -//! descriptors ride with each message and which object it creates, so the -//! object map stays in step with both peers. +//! Message signatures (protocol_tables.rs): the descriptors each carries and the object it creates. use crate::wire::{ArgReader, WireError}; @@ -24,8 +22,7 @@ pub struct Interface { pub events: &'static [Message], } -/// The interface of that name; None is a refusal in every caller. Indexed on -/// first use, since this runs for every new object and every advertised global. +/// The interface of that name, indexed on first use: this runs for every new object and global. pub fn find(name: &str) -> Option<&'static Interface> { use std::collections::HashMap; use std::sync::OnceLock; @@ -44,8 +41,7 @@ pub enum Arg { Fixed, String { nullable: bool }, Object { nullable: bool }, - /// A new object. `iface` is None for wl_registry.bind, whose new_id follows - /// the interface name and version the client chose. + /// A new object; `iface` is None for wl_registry.bind, where the client names it on the wire. NewId { iface: Option<&'static str> }, Array, Fd, @@ -57,12 +53,10 @@ impl Message { } } -/// The object a message creates and the strings it carries (for rewriting), -/// borrowed from the body: decoding allocates only for a message with strings. +/// The object a message creates and its strings, borrowed: only a message with strings allocates. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Decoded<'a> { - /// (new object id, interface name). No message creates two (the tables are - /// tested for it), and a body that tries is refused. + /// (id, interface). No message in the tables creates two; a body that tries is refused. pub new_object: Option<(u32, &'a str)>, /// (byte offset of the string's length word within the body, value) pub strings: Vec<(usize, &'a str)>, @@ -80,8 +74,7 @@ impl<'a> Decoded<'a> { } } -/// Validate a body against its signature and collect what the proxy needs. It -/// must parse exactly to its end: a trailing byte is as suspect as a missing one. +/// Check a body against its signature to its exact end: a trailing byte is as bad as a missing one. pub fn decode<'a>(msg: &Message, body: &'a [u8]) -> Result, WireError> { let mut r = ArgReader::new(body); let mut d = Decoded::default(); diff --git a/compositor/wlproxy/src/protocol/tests.rs b/compositor/wlproxy/src/protocol/tests.rs index f244d8b1..9dc1e2d4 100644 --- a/compositor/wlproxy/src/protocol/tests.rs +++ b/compositor/wlproxy/src/protocol/tests.rs @@ -75,11 +75,7 @@ fn strings_are_located_for_rewriting() { assert_eq!(d.strings, vec![(0, "hello")]); } -// --- the decoder, attacked --------------------------------------------- -/* `Header::parse` and `decode` see every byte a client sends. A well-formed - * body for each message in the tables is damaged by a fixed-seed generator, - * so a failure repeats on every machine. The decoder must never panic or - * read past the body, and returns Ok only for an exact parse. */ +// --- Header::parse and decode see every byte a client sends: fuzz them --- /// xorshift64*: small, seeded, the same sequence everywhere. pub(crate) struct Rng(pub u64); @@ -175,7 +171,7 @@ fn decoder_survives_any_body() { } } } - // The generator must actually reach both sides of the decoder. + // The generator must reach both sides of the decoder. assert!(tried > 10_000 && accepted > tried / 50 && accepted < tried, "tried {tried}, accepted {accepted}"); } diff --git a/compositor/wlproxy/src/session.rs b/compositor/wlproxy/src/session.rs index dec7e085..8e4fbb44 100644 --- a/compositor/wlproxy/src/session.rs +++ b/compositor/wlproxy/src/session.rs @@ -1,11 +1,5 @@ -//! One proxied connection, zone client to compositor: framed (wire.rs) against -//! the tables (protocol.rs), checked, rewritten and forwarded. -//! -//! Every live object id is mapped to its interface. A message the map or the -//! tables cannot account for, a hidden bind or an exceeded bound ends the -//! session with one wl_display.error. Received descriptors queue in arrival -//! order; a message takes as many as its signature has `h` arguments, waiting -//! if they have not arrived, and sends them with its own bytes. +//! One proxied connection, zone client to compositor. A message the object map or the tables +//! cannot account for, a hidden bind or an exceeded bound ends it with one wl_display.error. use std::collections::{HashMap, VecDeque}; use std::io; @@ -81,8 +75,7 @@ impl From for SessionError { /// Bytes gathered into one outbound batch: the largest message. const BATCH_BYTES: usize = MAX_MESSAGE_LEN; -/// Descriptors in one sendmsg: libwayland reads at most 28 per message -/// (MAX_FDS_OUT) and ends the connection on more. +/// Descriptors per sendmsg: libwayland reads at most 28 (MAX_FDS_OUT) and hangs up on more. pub(crate) const BATCH_FDS: usize = 28; /// One socket: inbound bytes and descriptors, outbound batches with theirs. @@ -109,8 +102,7 @@ impl Endpoint { &self.inbuf[self.in_pos..] } - /// Move the next `n` pending bytes (one message) into the caller's reused - /// buffer; the caller has checked they are present. + /// Move the next `n` pending bytes into `out`; the caller has checked they are there. fn take(&mut self, n: usize, out: &mut Vec) { out.clear(); out.extend_from_slice(&self.inbuf[self.in_pos..self.in_pos + n]); @@ -130,8 +122,7 @@ impl Endpoint { bytes } - /// One recvmsg with room for descriptors. Returns bytes read: 0 is EOF, - /// usize::MAX that it would block. + /// One recvmsg with room for descriptors: bytes read, 0 at EOF, usize::MAX if it would block. pub fn read(&mut self) -> io::Result { let mut buf = [0u8; 4096]; let mut cmsg = [0usize; 32]; // cmsghdr needs native alignment @@ -149,7 +140,7 @@ impl Endpoint { } return Err(e); } - // Collect descriptors before the bytes, in order. + // Queue the descriptors in order before any check, so a refused read cannot leak them. unsafe { let mut c = libc::CMSG_FIRSTHDR(&msg); while !c.is_null() { @@ -164,8 +155,7 @@ impl Endpoint { c = libc::CMSG_NXTHDR(&msg, c); } } - /* Bounds hold at ingress too, even while pump waits for a missing - * descriptor: nothing may accumulate behind it. */ + // Bounds hold at ingress too, so nothing piles up while pump waits for a descriptor. if msg.msg_flags & libc::MSG_CTRUNC != 0 || self.in_fds.len() > policy::MAX_PENDING_FDS || self.pending_in().len() + n as usize > policy::MAX_PENDING_BYTES @@ -175,7 +165,7 @@ impl Endpoint { if n == 0 { return Ok(0); } - // One compaction per read, here, rather than one per message. + // Compact once per read, not once per message. if self.in_pos > 0 { self.inbuf.drain(..self.in_pos); self.in_pos = 0; @@ -217,8 +207,7 @@ impl Endpoint { return Err(e); } let n = n as usize; - /* The descriptors went with the first byte and must not go again - * with the rest: close our copies. */ + // The descriptors went with the first byte: close ours, so the rest goes without them. self.pending_fds -= fds.len(); for fd in fds.drain(..) { unsafe { libc::close(fd) }; @@ -233,9 +222,7 @@ impl Endpoint { Ok(false) } - /// Queue one message, joining the last batch while it has room. Its - /// descriptors go with that batch, so they arrive in order and no later - /// than the message, which is all libwayland asks. + /// Add to the last batch if it fits; fds ride with it, in order and never after their message. fn queue(&mut self, bytes: &[u8], fds: Vec) { self.pending_out += bytes.len(); self.pending_fds += fds.len(); @@ -270,10 +257,7 @@ impl Endpoint { /// A live object: its interface from the tables and its negotiated version. type Obj = (&'static protocol::Interface, u32); -/// Live objects by id. libwayland hands out ids densely, the client's from 1 -/// and the compositor's from SERVER_ID_BASE, and libwayland-server refuses a -/// new id past the next unused slot. So each range is a vector, and an id that -/// skips ahead is refused here as the compositor would refuse it. +/// Live objects, a vector per id range: libwayland ids are dense, and one that skips is refused. struct Objects { client: Vec>, server: Vec>, @@ -335,13 +319,14 @@ impl Objects { } } -/// The proxied connection. +/// A wl_shm pool's charge on the budgets, kept until the pool and its buffers are all deleted. struct Pool { size: usize, buffers: usize, deleted: bool, } +/// The proxied connection. pub struct Session { pub zone: String, pub client: Endpoint, @@ -354,9 +339,8 @@ pub struct Session { pub shm_pool_bytes: usize, pub shm_pool_count: usize, pub toplevels: usize, - /// Globals the server advertised and we let through: name -> (interface, version) + /// Globals let through to the client: name -> (interface, version cap). globals: HashMap, - /// How many globals were hidden from the client. pub hidden_count: usize, pub forwarded_c2s: u64, pub forwarded_s2c: u64, @@ -416,9 +400,8 @@ impl Session { if !ok || id == 0 { return Err(SessionError::IdOutOfRange { id, dir }); } - /* An interface missing from the tables cannot be tracked. Binds are - * checked against the allowlist, so only a compositor newer than the - * tables can create one: refuse rather than guess. */ + /* Refuse an interface the tables lack: binds are allowlisted, so only a + * compositor newer than the tables can name one. */ let iface = protocol::find(iface_name).ok_or_else(|| SessionError::HiddenInterface(iface_name.to_string()))?; if iface.name == "xdg_toplevel" && self.toplevels >= policy::MAX_TOPLEVELS_PER_SESSION { return Err(SessionError::ResourceLimit("too many toplevels in one session")); @@ -478,7 +461,7 @@ impl Session { let mut stamp: Option> = None; match dir { Dir::ClientToServer => { - // get_registry needs nothing here: its registry is registered below like any child. + // get_registry needs no check here; its new registry is registered below. if iface.name == "wl_registry" && h.opcode == WL_REGISTRY_BIND { let (name, version) = match (decoded.new_object, decoded.bind_version) { (Some((_, n)), Some(v)) => (n, v), @@ -524,8 +507,8 @@ impl Session { || self.shm_pool_bytes + size as usize > policy::MAX_SHM_BYTES_PER_SESSION { return Err(SessionError::ResourceLimit("wl_shm pool budget in one session")); } - // A destroyed pool may still back buffers. Its generation keeps - // those buffers separate if Wayland later reuses the object id. + /* A destroyed pool may still back buffers; the generation keeps + * them apart from a new pool on the same object id. */ self.next_pool += 1; self.shm_pool_count += 1; self.shm_pool_bytes += size as usize; @@ -553,13 +536,11 @@ impl Session { self.buffer_pools.insert(id, generation); self.pools.get_mut(&generation).unwrap().buffers += 1; } - /* Every toplevel gets the zone's app_id right behind the - * get_toplevel that creates it: the compositor draws a toplevel - * with no app_id as zone 0's own, with the trusted border. A - * later set_app_id from the client is rewritten and replaces it. */ + /* Stamp the zone's app_id right behind get_toplevel: the compositor draws + * a toplevel with no app_id as zone 0's own, with the trusted border. */ if iface.name == "xdg_surface" && m.name == "get_toplevel" { if let Some((id, _)) = decoded.new_object { - // Resolved once; tables without set_app_id refuse rather than guess an opcode. + // Looked up once, not hardcoded; tables without set_app_id refuse. static SET_APP_ID: std::sync::OnceLock> = std::sync::OnceLock::new(); let opcode = SET_APP_ID .get_or_init(|| { @@ -616,8 +597,7 @@ impl Session { } } } - /* The new object, whichever side created it. A registry binding - * chooses its version; any other child inherits its parent's. */ + // A bind picks the new object's version; any other new object inherits its parent's. if let Some((id, name)) = decoded.new_object { self.register(id, name, decoded.bind_version.unwrap_or(version), dir)?; } @@ -647,8 +627,7 @@ impl Session { } } - /// Tell the client why with a fatal wl_display.error (code 3, - /// implementation), then close both sides. + /// Tell the client why in a fatal wl_display.error (code 3, implementation); close both sides. pub fn refuse(&mut self, why: &str) { let text = format!("kryptik-wlproxy: {why}"); if let Some(m) = MessageWriter::new(WL_DISPLAY, WL_DISPLAY_ERROR).u32(WL_DISPLAY).u32(3).string(&text).finish() { diff --git a/compositor/wlproxy/src/session/tests.rs b/compositor/wlproxy/src/session/tests.rs index 77815328..88ff8c4c 100644 --- a/compositor/wlproxy/src/session/tests.rs +++ b/compositor/wlproxy/src/session/tests.rs @@ -43,6 +43,33 @@ fn get_registry(id: u32) -> Vec { fn global(reg: u32, name: u32, iface: &str, version: u32) -> Vec { MessageWriter::new(reg, WL_REGISTRY_GLOBAL).u32(name).string(iface).u32(version).finish().unwrap() } +/// A session with toplevel 7 created and the compositor's end drained. +fn with_toplevel() -> (Session, UnixStream, UnixStream) { + let (mut s, mut c, mut sv) = make(); + c.write_all(&get_registry(2)).unwrap(); + sv.write_all(&global(2, 1, "wl_compositor", 6)).unwrap(); + sv.write_all(&global(2, 2, "xdg_wm_base", 6)).unwrap(); + pump_all(&mut s).unwrap(); + c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(1).string("wl_compositor").u32(6).u32(3).finish().unwrap()).unwrap(); + c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(2).string("xdg_wm_base").u32(6).u32(4).finish().unwrap()).unwrap(); + c.write_all(&MessageWriter::new(3, 0).u32(5).finish().unwrap()).unwrap(); // create_surface -> 5 + c.write_all(&MessageWriter::new(4, 2).u32(6).u32(5).finish().unwrap()).unwrap(); // get_xdg_surface -> 6 + c.write_all(&MessageWriter::new(6, 1).u32(7).finish().unwrap()).unwrap(); // get_toplevel -> 7 + pump_all(&mut s).unwrap(); + let _ = read_all(&mut sv); + (s, c, sv) +} +/// A session with wl_shm v2 bound as object 3 and the compositor's end drained. +fn with_shm() -> (Session, UnixStream, UnixStream) { + let (mut s, mut c, mut sv) = make(); + c.write_all(&get_registry(2)).unwrap(); + sv.write_all(&global(2, 1, "wl_shm", 2)).unwrap(); + pump_all(&mut s).unwrap(); + c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(1).string("wl_shm").u32(2).u32(3).finish().unwrap()).unwrap(); + pump_all(&mut s).unwrap(); + let _ = read_all(&mut sv); + (s, c, sv) +} #[test] fn hidden_globals_invisible_and_unbindable() { @@ -129,7 +156,7 @@ fn bind_must_match_advertised_global() { } #[test] -fn new_object_cannot_replace_live_one() { +fn live_object_not_replaced() { let (mut s, mut c, mut sv) = make(); c.write_all(&get_registry(2)).unwrap(); pump_all(&mut s).unwrap(); @@ -183,7 +210,7 @@ fn id_slots_bounded() { } #[test] -fn popup_is_refused_before_it_reaches_dwl() { +fn popup_refused() { let (mut s, mut c, mut sv) = make(); s.objects.place(2, (protocol::find("xdg_surface").unwrap(), 1)); s.objects.place(3, (protocol::find("xdg_positioner").unwrap(), 1)); @@ -194,7 +221,7 @@ fn popup_is_refused_before_it_reaches_dwl() { } #[test] -fn toplevel_budget_reclaims_only_on_compositor_delete_id() { +fn toplevel_limit_and_reclaim() { let (mut s, mut c, mut sv) = make(); s.objects.place(2, (protocol::find("xdg_surface").unwrap(), 1)); for id in 3..3 + policy::MAX_TOPLEVELS_PER_SESSION as u32 { @@ -214,7 +241,7 @@ fn toplevel_budget_reclaims_only_on_compositor_delete_id() { } #[test] -fn shm_pool_creation_and_resize_have_byte_and_count_limits() { +fn shm_pool_limits() { let (mut s, mut c, mut sv) = make(); s.objects.place(3, (protocol::find("wl_shm").unwrap(), 1)); let (fd, _) = UnixStream::pair().unwrap(); @@ -256,7 +283,7 @@ fn shm_pool_creation_and_resize_have_byte_and_count_limits() { } #[test] -fn pool_budget_follows_buffers_across_reused_object_ids() { +fn pool_budget_follows_buffers() { let (mut s, mut c, mut sv) = make(); s.objects.place(3, (protocol::find("wl_shm").unwrap(), 1)); let (fd, _) = UnixStream::pair().unwrap(); @@ -309,19 +336,8 @@ fn messages_respect_bound_versions() { } #[test] -fn titles_and_app_ids_are_rewritten() { - let (mut s, mut c, mut sv) = make(); - c.write_all(&get_registry(2)).unwrap(); - sv.write_all(&global(2, 1, "wl_compositor", 6)).unwrap(); - sv.write_all(&global(2, 2, "xdg_wm_base", 6)).unwrap(); - pump_all(&mut s).unwrap(); - c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(1).string("wl_compositor").u32(6).u32(3).finish().unwrap()).unwrap(); - c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(2).string("xdg_wm_base").u32(6).u32(4).finish().unwrap()).unwrap(); - c.write_all(&MessageWriter::new(3, 0).u32(5).finish().unwrap()).unwrap(); // create_surface -> 5 - c.write_all(&MessageWriter::new(4, 2).u32(6).u32(5).finish().unwrap()).unwrap(); // get_xdg_surface -> 6 - c.write_all(&MessageWriter::new(6, 1).u32(7).finish().unwrap()).unwrap(); // get_toplevel -> 7 - pump_all(&mut s).unwrap(); - let _ = read_all(&mut sv); +fn titles_and_app_ids_rewritten() { + let (mut s, mut c, mut sv) = with_toplevel(); c.write_all(&MessageWriter::new(7, 2).string("Notes").finish().unwrap()).unwrap(); // set_title c.write_all(&MessageWriter::new(7, 3).string("editor").finish().unwrap()).unwrap(); // set_app_id pump_all(&mut s).unwrap(); @@ -350,8 +366,7 @@ fn unnamed_toplevel_is_stamped() { c.write_all(&MessageWriter::new(5, 6).finish().unwrap()).unwrap(); // wl_surface.commit, and no set_app_id ever pump_all(&mut s).unwrap(); let got = read_all(&mut sv); - let msgs = split_messages_for_test(&got); - // get_toplevel, then the proxy's set_app_id on the new object, then the commit. + let msgs = split_messages(&got); assert_eq!(msgs[0].0, (6, 1), "get_toplevel is forwarded first: {msgs:?}"); assert_eq!(msgs[1].0, (7, 3), "the proxy's set_app_id follows on the new toplevel: {msgs:?}"); assert_eq!(msgs[2].0, (5, 6), "the commit comes after the identity: {msgs:?}"); @@ -366,7 +381,7 @@ fn unnamed_toplevel_is_stamped() { } /// (object, opcode) and body of each message in a byte stream. -fn split_messages_for_test(mut bytes: &[u8]) -> Vec<((u32, u16), Vec)> { +fn split_messages(mut bytes: &[u8]) -> Vec<((u32, u16), Vec)> { let mut out = Vec::new(); while bytes.len() >= HEADER_LEN { let h = Header::parse(bytes).unwrap(); @@ -380,18 +395,7 @@ fn split_messages_for_test(mut bytes: &[u8]) -> Vec<((u32, u16), Vec)> { /// Bounded, prefixed, still valid UTF-8, and the session survives it. #[test] fn long_multibyte_title_is_forwarded() { - let (mut s, mut c, mut sv) = make(); - c.write_all(&get_registry(2)).unwrap(); - sv.write_all(&global(2, 1, "wl_compositor", 6)).unwrap(); - sv.write_all(&global(2, 2, "xdg_wm_base", 6)).unwrap(); - pump_all(&mut s).unwrap(); - c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(1).string("wl_compositor").u32(6).u32(3).finish().unwrap()).unwrap(); - c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(2).string("xdg_wm_base").u32(6).u32(4).finish().unwrap()).unwrap(); - c.write_all(&MessageWriter::new(3, 0).u32(5).finish().unwrap()).unwrap(); - c.write_all(&MessageWriter::new(4, 2).u32(6).u32(5).finish().unwrap()).unwrap(); - c.write_all(&MessageWriter::new(6, 1).u32(7).finish().unwrap()).unwrap(); - pump_all(&mut s).unwrap(); - let _ = read_all(&mut sv); + let (mut s, mut c, mut sv) = with_toplevel(); let title = "\u{00e9}".repeat(200); // 400 bytes; byte 253 is mid-character c.write_all(&MessageWriter::new(7, 2).string(&title).finish().unwrap()).unwrap(); pump_all(&mut s).unwrap(); @@ -467,13 +471,7 @@ fn batches_small_messages() { #[test] fn descriptors_ride_with_their_message() { - let (mut s, mut c, mut sv) = make(); - c.write_all(&get_registry(2)).unwrap(); - sv.write_all(&global(2, 1, "wl_shm", 2)).unwrap(); - pump_all(&mut s).unwrap(); - c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(1).string("wl_shm").u32(2).u32(3).finish().unwrap()).unwrap(); - pump_all(&mut s).unwrap(); - let _ = read_all(&mut sv); + let (mut s, mut c, sv) = with_shm(); // wl_shm.create_pool(new_id pool, fd, size): send bytes and one fd together let (probe_a, probe_b) = UnixStream::pair().unwrap(); let msg = MessageWriter::new(3, 0).u32(4).i32(4096).finish().unwrap(); @@ -491,10 +489,9 @@ fn descriptors_ride_with_their_message() { let mut buf = [0u8; 16]; let n = probe_b.read(&mut buf).unwrap(); assert_eq!(&buf[..n], b"same file"); - /* A message with no fd argument must not take one: an fd sent with - * wl_shm.release goes with the create_pool that follows. */ + // An fd sent with wl_shm.release, which takes none, goes with the next create_pool. let (extra_a, _extra_b) = UnixStream::pair().unwrap(); - send_with_fd(c.as_raw_fd(), &MessageWriter::new(3, 1).finish().unwrap(), extra_a.as_raw_fd()); // wl_shm.release (v2), carries no fd + send_with_fd(c.as_raw_fd(), &MessageWriter::new(3, 1).finish().unwrap(), extra_a.as_raw_fd()); c.write_all(&MessageWriter::new(3, 0).u32(5).i32(8192).finish().unwrap()).unwrap(); pump_all(&mut s).unwrap(); let (bytes, fds) = recv_with_fds(sv.as_raw_fd()); @@ -505,13 +502,7 @@ fn descriptors_ride_with_their_message() { #[test] fn message_waits_for_descriptor() { - let (mut s, mut c, mut sv) = make(); - c.write_all(&get_registry(2)).unwrap(); - sv.write_all(&global(2, 1, "wl_shm", 2)).unwrap(); - pump_all(&mut s).unwrap(); - c.write_all(&MessageWriter::new(2, WL_REGISTRY_BIND).u32(1).string("wl_shm").u32(2).u32(3).finish().unwrap()).unwrap(); - pump_all(&mut s).unwrap(); - let _ = read_all(&mut sv); + let (mut s, mut c, mut sv) = with_shm(); // most of the bytes first, no fd: nothing is forwarded yet let msg = MessageWriter::new(3, 0).u32(4).i32(4096).finish().unwrap(); c.write_all(&msg[..12]).unwrap(); @@ -548,7 +539,7 @@ fn rejected_message_closes_its_descriptors() { } #[test] -fn surplus_descriptors_are_bounded_at_ingress() { +fn inbound_descriptors_bounded() { let (mut s, c, _sv) = make(); let (a, mut b) = UnixStream::pair().unwrap(); b.set_nonblocking(true).unwrap(); @@ -598,7 +589,7 @@ fn outgoing_descriptors_are_bounded() { } #[test] -fn input_bounded_while_waiting_for_fd() { +fn input_bounded_awaiting_fd() { let (mut s, mut c, _sv) = make(); s.objects.place(3, (protocol::find("wl_shm").unwrap(), 2)); c.write_all(&MessageWriter::new(3, 0).u32(4).i32(4096).finish().unwrap()).unwrap(); @@ -617,7 +608,7 @@ fn input_bounded_while_waiting_for_fd() { } #[test] -fn disconnect_and_refusal_close_both_sides() { +fn refusal_closes_both_sides() { let (mut s, mut c, sv) = make(); c.write_all(&get_registry(2)).unwrap(); pump_all(&mut s).unwrap(); @@ -694,9 +685,7 @@ fn recv_with_fds(sock: RawFd) -> (Vec, Vec) { (bytes, fds) } -/// A real opening conversation, damaged by the seeded generator and sent in -/// random fragments. The session may refuse but never panic, and all it -/// forwards must be whole, well-formed messages. +/// Damaged openings sent in random fragments: no panic, and only whole messages forwarded. #[test] fn compositor_gets_only_whole_messages() { use crate::protocol::tests::Rng; @@ -713,7 +702,6 @@ fn compositor_gets_only_whole_messages() { let (mut refused, mut through) = (0u32, 0u32); for round in 0..400 { let (mut s, mut c, mut sv) = make(); - // Once get_registry has crossed, the compositor advertises the global to bind. let mut bytes = conversation.clone(); if round > 0 { for _ in 0..1 + rng.below(3) { @@ -726,6 +714,7 @@ fn compositor_gets_only_whole_messages() { } } } + // Once get_registry has crossed, the compositor advertises the global to bind. let mut advertised = false; let mut outcome = Ok(()); let mut rest: &[u8] = &bytes; @@ -750,7 +739,7 @@ fn compositor_gets_only_whole_messages() { } if round == 0 { assert!(outcome.is_ok(), "the undamaged conversation was refused: {:?}", outcome.err().map(|e| e.to_string())); - assert!(s.has_object(2) && s.has_object(3), "the undamaged conversation did not do what it says"); + assert!(s.has_object(2) && s.has_object(3), "the undamaged conversation did not bind the compositor"); } s.client.close_all(); s.server.close_all(); diff --git a/compositor/wlproxy/src/wire.rs b/compositor/wlproxy/src/wire.rs index 3e5894d6..b657c68f 100755 --- a/compositor/wlproxy/src/wire.rs +++ b/compositor/wlproxy/src/wire.rs @@ -1,18 +1,15 @@ -//! The Wayland wire format, parsed defensively: every length comes from a zone, -//! so none is used unchecked, and no input can make this panic. +//! The Wayland wire format, parsed defensively: lengths come from a zone, and no input may panic. //! -//! A message is a header (object id; `size << 16 | opcode`, `size` counting the -//! header) and arguments padded to 4 bytes, all in host byte order. A string's -//! length includes its NUL, and 0 means null. An fd takes no bytes: it travels -//! by SCM_RIGHTS, so only the tables (protocol.rs) say how many a message -//! carries, and a miscount hands a client another message's descriptor. +//! A message is a header (object id; `size << 16 | opcode`, `size` counting the header) and +//! arguments padded to 4 bytes, in host byte order. A string's length includes its NUL; 0 is null. +//! An fd takes no bytes: it travels by SCM_RIGHTS, so only the tables (protocol.rs) say how many +//! a message carries, and a miscount hands a client another message's descriptor. use std::fmt; pub const HEADER_LEN: usize = 8; -/// Largest message. libwayland never sends more than its 4096-byte buffer; the -/// 16-bit size field could claim 64 KiB for the proxy to buffer. +/// Largest message: libwayland sends at most its 4096-byte buffer, though the size field allows 64 KiB. pub const MAX_MESSAGE_LEN: usize = 4096; /// Ids from here up are the server's; a client creating one is refused. @@ -30,8 +27,7 @@ pub enum WireError { UnterminatedString, /// A string that is not valid UTF-8, which every Wayland string must be. NotUtf8, - /// A NUL inside a string: `"wl_shm\0_evil"` equals `"wl_shm"` only to a - /// C-string comparison, and policy must not depend on which kind runs. + /// A NUL inside a string: C would read `"wl_shm\0_evil"` as `"wl_shm"`. InteriorNul, } @@ -61,8 +57,7 @@ pub struct Header { } impl Header { - /// Parse a header from the first 8 bytes of `buf`. `size` is validated - /// here, so no `Header` can carry a bad length. + /// Parse the first 8 bytes of `buf`; `size` is checked here, so no `Header` has a bad length. pub fn parse(buf: &[u8]) -> Result { if buf.len() < HEADER_LEN { return Err(WireError::Truncated); diff --git a/compositor/wlproxy/src/wire/tests.rs b/compositor/wlproxy/src/wire/tests.rs index 33b6160f..13637e3e 100644 --- a/compositor/wlproxy/src/wire/tests.rs +++ b/compositor/wlproxy/src/wire/tests.rs @@ -170,7 +170,7 @@ fn writer_output_is_aligned() { let msg = MessageWriter::new(1, 0).string(s).finish().unwrap(); assert_eq!(msg.len() % 4, 0, "string {s:?} produced {} bytes", msg.len()); let mut r = ArgReader::new(&msg[HEADER_LEN..]); - assert_eq!(r.string().unwrap(), Some(s).filter(|x: &&str| !x.is_empty()).or(Some(""))); + assert_eq!(r.string().unwrap(), Some(s)); } } diff --git a/compositor/wlproxy/tests/live.rs b/compositor/wlproxy/tests/live.rs index 6bb5100a..ee0dea28 100644 --- a/compositor/wlproxy/tests/live.rs +++ b/compositor/wlproxy/tests/live.rs @@ -1,6 +1,4 @@ -//! The proxy as a process, including the event loop that session.rs's unit -//! tests cannot reach. The "upstream" is a Unix listener the test owns, -//! speaking hand-encoded wire messages, so no compositor is needed. +//! The proxy as a process, event loop included; the upstream is the test's own Unix listener. use std::io::{ErrorKind, Read, Write}; use std::os::unix::io::{AsRawFd, RawFd}; @@ -258,8 +256,7 @@ fn serves_clients_through_churn() { assert_eq!(read_exact_or_panic(&mut u1, 12, "client 1's get_registry at the upstream"), get_registry(2)); p.assert_alive("after its first client's first request"); - /* Events flow back filtered: an allowed global arrives, a hidden one does - * not, and the next allowed one arrives right after it. */ + // Events come back filtered: the hidden global between two allowed ones never arrives. u1.write_all(&global(2, 1, "wl_compositor", 6)).unwrap(); let want = global(2, 1, "wl_compositor", 6); assert_eq!(read_exact_or_panic(&mut c1, want.len(), "wl_compositor global at client 1"), want); @@ -333,9 +330,7 @@ fn send_fds(s: &UnixStream, data: &[u8], fds: &[RawFd]) { } } -/// The proxy raises its soft descriptor limit to the hard one and serves only -/// the clients whose queued descriptors fit, so one that parks as many as it -/// may cannot starve the sessions already open. +/// Only clients whose queued fds fit the raised limit are served, so none starves the open ones. #[test] fn clients_fit_descriptor_limit() { // (300 - 8) / 130: two sessions. @@ -404,8 +399,7 @@ fn once_serves_one_client() { assert_no_stale_socket(&listen); } -/// Long multi-byte titles reach the upstream bounded, prefixed and valid, and -/// the proxy survives them. +/// Long multi-byte titles reach the upstream bounded, prefixed and valid. #[test] fn long_unicode_titles_are_rewritten() { let mut p = Proxy::start("work", &[]); @@ -414,7 +408,7 @@ fn long_unicode_titles_are_rewritten() { let _ = read_exact_or_panic(&mut u, 12, "get_registry"); u.write_all(&global(2, 1, "wl_compositor", 6)).unwrap(); u.write_all(&global(2, 2, "xdg_wm_base", 6)).unwrap(); - let _ = read_until(&mut c, |b| split_messages_ok(b, 2), "both globals at the client"); + let _ = read_until(&mut c, |b| holds_messages(b, 2), "both globals at the client"); c.write_all(&bind(2, 1, "wl_compositor", 6, 3)).unwrap(); c.write_all(&bind(2, 2, "xdg_wm_base", 6, 4)).unwrap(); @@ -425,7 +419,7 @@ fn long_unicode_titles_are_rewritten() { c.write_all(&msg(4, 2, &body)).unwrap(); // xdg_wm_base.get_xdg_surface -> 6 c.write_all(&msg(6, 1, &u32ne(7))).unwrap(); // xdg_surface.get_toplevel -> 7 // Six: the proxy's app_id stamp follows get_toplevel. - let _ = read_until(&mut u, |b| split_messages_ok(b, 6), "the five setup requests and the stamped app_id at the upstream"); + let _ = read_until(&mut u, |b| holds_messages(b, 6), "the five setup requests and the stamped app_id at the upstream"); for (label, title) in [ ("accented", "\u{00e9}".repeat(200)), @@ -434,7 +428,7 @@ fn long_unicode_titles_are_rewritten() { ("ascii", "Editor".to_string()), ] { c.write_all(&msg(7, 2, &wl_string(&title))).unwrap(); // xdg_toplevel.set_title - let bytes = read_until(&mut u, |b| split_messages_ok(b, 1), &format!("the {label} title at the upstream")); + let bytes = read_until(&mut u, |b| holds_messages(b, 1), &format!("the {label} title at the upstream")); let msgs = split_messages(&bytes); assert_eq!(msgs.len(), 1); assert_eq!((msgs[0].0, msgs[0].1), (7, 2)); @@ -451,7 +445,7 @@ fn long_unicode_titles_are_rewritten() { } /// Whether `bytes` holds exactly `n` complete messages and nothing else. -fn split_messages_ok(mut bytes: &[u8], n: usize) -> bool { +fn holds_messages(mut bytes: &[u8], n: usize) -> bool { let mut count = 0; while bytes.len() >= 8 { let word = u32::from_ne_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); diff --git a/compositor/zoneid/Cargo.toml b/compositor/zoneid/Cargo.toml index 677b3678..6ec5fee0 100755 --- a/compositor/zoneid/Cargo.toml +++ b/compositor/zoneid/Cargo.toml @@ -5,8 +5,7 @@ version.workspace = true edition.workspace = true license.workspace = true -# No dependencies: the colour maths is published formulae checked against -# reference values in the tests (the ADR-010 argument, as for kryptikd). +# No dependencies (ADR-010): published colour formulae, tested against reference values. [dependencies] [[bin]] diff --git a/compositor/zoneid/src/bin/zoneid.rs b/compositor/zoneid/src/bin/zoneid.rs index 9bd0331a..4a325930 100755 --- a/compositor/zoneid/src/bin/zoneid.rs +++ b/compositor/zoneid/src/bin/zoneid.rs @@ -1,7 +1,5 @@ //! zoneid: audit zone border colours, propose palettes, simulate vision models. -//! -//! Exit codes: 0 clean or informational, 1 the zone set fails the invariant, -//! 2 usage error, 3 the zone files could not be read. +//! Exit 0 clean or informational, 1 the zone set fails, 2 usage error, 3 unreadable zone files. use std::path::PathBuf; use std::process::ExitCode; @@ -32,7 +30,7 @@ Usage: Show colours as they appear under each vision model. zoneid explain - What the invariant is and why it is shaped this way. + Explain the invariant and its channels. Zones are read from compartments/zones/*.toml by default." } @@ -62,8 +60,7 @@ fn main() -> ExitCode { } } -/// Parse `flag value` pairs, each known flag at most once. Anything else is a -/// usage error, so a misspelt `--zone X` cannot audit the default set and pass. +/// Known `flag value` pairs, each once; a misspelt `--zone X` errs instead of auditing the default. fn options<'a>(args: &'a [String], known: &[&str]) -> Result, String> { let mut out: Vec<(&str, &str)> = Vec::new(); let mut it = args.iter(); @@ -94,8 +91,7 @@ fn cmd_audit(args: &[String]) -> ExitCode { return ExitCode::from(2); } }; - /* --zones, else compartments/zones here, else the set shipped beside this - * crate, so cargo run works from anywhere in the tree. */ + // --zones, else ./compartments/zones, else the crate's own tree, so cargo run works anywhere. let dir = match flag(&args, "--zones") { Some(d) => PathBuf::from(d), None => { @@ -209,9 +205,7 @@ fn cmd_audit(args: &[String]) -> ExitCode { let crit = r.critical().count(); if r.is_fatal() { println!( - "FAIL: {crit} critical collision(s). Two border colours the compositor draws\n\ - are the same window edge to some users, so a window cannot be attributed\n\ - by looking at it." + "FAIL: {crit} critical collision(s): some users cannot tell two border colours apart." ); ExitCode::from(1) } else { @@ -296,10 +290,7 @@ fn cmd_propose(args: &[String]) -> ExitCode { println!(" {:<14} worst pair dE00 {:>6.2}", v.name(), d); } println!("\n overall floor: dE00 {:.2}", p.score); - println!( - "\nHeuristic search, not a proven optimum: it establishes a lower bound on\n\ - what is achievable under these constraints." - ); + println!("\nHeuristic search: a lower bound on what these constraints allow, not an optimum."); ExitCode::SUCCESS } @@ -360,9 +351,8 @@ const EXPLAIN: &str = "\ Zone distinctness A window's border colour is how the user tells which zone it belongs to -(docs/architecture.md). If they cannot tell at a glance which zone a password -prompt belongs to, the zones have failed them. That is a question of -perception, so it is checked against a model of it, not by comparing strings. +(docs/architecture.md). That is a question of perception, so colours are +checked against a model of it, not compared as strings. The rule: every two border colours the compositor draws (each zone's, and its own for a window from no zone, from an unknown zone, or asking for attention) @@ -375,12 +365,12 @@ Channels: label the chrome menu and its f seen when asked for pattern not drawn validated, given no weight -Focus is shown by border width, never by colour, so the window taking your -keystrokes carries exactly its zone's audited colour. +Focus is shown by border width, never by colour, so the focused window +carries its zone's audited colour. -A pass is a floor, not a guarantee: it says two identities differ under a -stated vision model by a stated metric, not that nobody in a hurry, in poor -light, on a badly calibrated screen could confuse them. +A pass is a floor, not a guarantee: two identities differ by a stated metric +under each vision model, which does not rule out confusion in poor light or +on a badly calibrated screen. "; // A binary's root finds a module beside itself; the tests sit under its name. diff --git a/compositor/zoneid/src/color.rs b/compositor/zoneid/src/color.rs index 81787de6..dd6d8845 100755 --- a/compositor/zoneid/src/color.rs +++ b/compositor/zoneid/src/color.rs @@ -1,8 +1,5 @@ -//! Colour science from published formulae, each tested against reference -//! values (CIEDE2000 against the Sharma, Wu & Dalal 2005 data). -//! -//! `Srgb` is gamma-encoded and `LinearRgb` light-linear, both in 0..=1 (not -//! 0..=255). Vision simulation and luminance work on linear values only. +//! Colour science from published formulae, each tested against reference values. +//! Components are in 0..=1, not 0..=255; vision simulation and luminance use linear RGB. use std::fmt; @@ -14,8 +11,7 @@ pub struct Srgb { pub b: f64, } -/// A light-linear RGB colour, nominally in 0..=1. Vision simulation can leave -/// that range; values are clamped only on conversion back to `Srgb`. +/// A light-linear RGB colour, nominally in 0..=1; clamped only on conversion back to `Srgb`. #[derive(Clone, Copy, PartialEq, Debug)] pub struct LinearRgb { pub r: f64, @@ -59,8 +55,7 @@ impl fmt::Display for ParseHexError { } impl Srgb { - /// Parse `#RRGGBB` only: no short form, alpha or names. Coercing a bad zone - /// colour could give a zone another zone's identity. + /// Parse `#RRGGBB` only: coercing another form could give a zone another zone's colour. pub fn from_hex(s: &str) -> Result { let bytes = s.as_bytes(); if bytes.first() != Some(&b'#') { @@ -131,8 +126,7 @@ const RGB_TO_XYZ: [[f64; 3]; 3] = [ [0.019_333_9, 0.119_192_0, 0.950_304_1], ]; -/// The reference white, derived from `RGB_TO_XYZ`: the rounded matrix misses -/// canonical D65 slightly (Y sums to 1.0000001), and deriving keeps L* = 100. +/// The reference white from `RGB_TO_XYZ`'s row sums, so white is L* = 100 despite the rounding. const WHITE: (f64, f64, f64) = ( RGB_TO_XYZ[0][0] + RGB_TO_XYZ[0][1] + RGB_TO_XYZ[0][2], RGB_TO_XYZ[1][0] + RGB_TO_XYZ[1][1] + RGB_TO_XYZ[1][2], @@ -193,8 +187,7 @@ pub fn contrast_ratio(a: Srgb, b: Srgb) -> f64 { (hi + 0.05) / (lo + 0.05) } -/// CIEDE2000 colour difference: CIE 142-2001 as corrected by Sharma, Wu & -/// Dalal (2005), with kL = kC = kH = 1. The corrections matter near blue. +/// CIEDE2000 with Sharma, Wu & Dalal's 2005 corrections (they matter near blue), kL = kC = kH = 1. pub fn ciede2000(p: Lab, q: Lab) -> f64 { const POW25_7: f64 = 6_103_515_625.0; // 25^7 diff --git a/compositor/zoneid/src/color/tests.rs b/compositor/zoneid/src/color/tests.rs index 0420a0cf..48e6c48e 100644 --- a/compositor/zoneid/src/color/tests.rs +++ b/compositor/zoneid/src/color/tests.rs @@ -73,12 +73,10 @@ fn delta_e_is_symmetric() { assert!(close(delta_e(a, b), delta_e(b, a), 1e-9)); } -/// Sharma, Wu & Dalal (2005), "The CIEDE2000 Color-Difference Formula: -/// Implementation Notes, Supplementary Test Data, and Mathematical -/// Observations", Table 1: pairs that catch the hue wraparounds and the RT -/// term near blue. +/// Sharma, Wu & Dalal (2005), "The CIEDE2000 Color-Difference Formula", Table 1: pairs that +/// catch the hue wraparounds and the RT term near blue. #[test] -fn ciede2000_against_sharma_reference_data() { +fn ciede2000_reference_data() { let cases: &[(f64, f64, f64, f64, f64, f64, f64)] = &[ // L1, a1, b1, L2, a2, b2, expect (50.0000, 2.6772, -79.7751, 50.0000, 0.0000, -82.7485, 2.0425), diff --git a/compositor/zoneid/src/cvd.rs b/compositor/zoneid/src/cvd.rs index ae9da9ed..0f86e064 100755 --- a/compositor/zoneid/src/cvd.rs +++ b/compositor/zoneid/src/cvd.rs @@ -1,10 +1,5 @@ -//! Colour-vision deficiency simulation, after Machado, Oliveira & Fernandes -//! (2009), "A Physiologically-based Model for Simulation of Color Vision -//! Deficiency", IEEE TVCG 15(6). -//! -//! Only dichromacy (severity 1.0) is modelled: the milder anomalous forms are -//! strictly less severe, so a palette that passes here passes for them too. -//! The matrices apply to linear RGB, and `simulate` does the conversion. +//! Colour-vision deficiency simulation (Machado, Oliveira & Fernandes 2009, IEEE TVCG 15(6)). +//! Only dichromacy (severity 1.0) is modelled: a palette that passes it passes the milder forms. use crate::color::{LinearRgb, Srgb}; diff --git a/compositor/zoneid/src/cvd/tests.rs b/compositor/zoneid/src/cvd/tests.rs index 7030a314..0195fabc 100644 --- a/compositor/zoneid/src/cvd/tests.rs +++ b/compositor/zoneid/src/cvd/tests.rs @@ -10,9 +10,8 @@ fn normal_vision_is_identity() { } #[test] -fn greys_unchanged_by_any_model() { - /* Each matrix's rows sum to about 1, so greys pass through; a - * transcription error in a matrix makes them drift. */ +fn greys_unchanged() { + // Each matrix's rows sum to about 1, so greys pass; a transcription error makes them drift. for hex in ["#000000", "#404040", "#808080", "#c0c0c0", "#ffffff"] { let c = Srgb::from_hex(hex).unwrap(); for v in Vision::ALL { diff --git a/compositor/zoneid/src/distinct.rs b/compositor/zoneid/src/distinct.rs index a994e80c..b39f500e 100755 --- a/compositor/zoneid/src/distinct.rs +++ b/compositor/zoneid/src/distinct.rs @@ -1,20 +1,15 @@ -//! The distinctness invariant. Every two border colours the compositor draws -//! (each zone's, and its own for unzoned, unknown and urgent windows) must -//! differ by the floor under every vision model, and zones must have distinct -//! glyphs and labels. Patterns are not drawn, so they separate nothing. +//! The distinctness invariant: every two border colours the compositor draws, its own included, +//! differ by the floor under every vision model, and zones have distinct glyphs and labels. use crate::color::{ciede2000, contrast_ratio, Lab, Srgb}; use crate::cvd::{simulate, Vision}; use crate::identity::{Channel, ZoneIdentity}; -/// The colour-difference floor, in CIEDE2000 units. About 1 is just noticeable -/// side by side in a lab, but a border is seen at the edge of vision on an -/// uncalibrated screen. `zoneid propose` reaches 15.70 for six colours under -/// the contrast rule, so 15.0 binds; the shipped colours reach 15.88. +/// The colour-difference floor in CIEDE2000 units (about 1 is just noticeable side by side), +/// set just under the 15.70 `zoneid propose` reaches for six colours under the contrast rule. pub const MIN_DELTA_E: f64 = 15.0; -/// Minimum contrast of a zone border against the background: 3:1, as WCAG 2.1 -/// SC 1.4.11 requires for user interface components. +/// Minimum border contrast against the background: 3:1, WCAG 2.1 SC 1.4.11 for UI components. pub const MIN_BORDER_CONTRAST: f64 = 3.0; /// Backgrounds a border is checked against: a colour tuned for one can vanish on the other. @@ -25,10 +20,8 @@ pub fn backgrounds() -> Vec<(&'static str, Srgb)> { BACKGROUNDS.iter().filter_map(|&(name, hex)| Some((name, Srgb::from_hex(hex).ok()?))).collect() } -/// The compositor's own border colours: unzoned windows (the chrome), zones it -/// has no colour for, and urgent windows. gen-zone-colours.py writes the same -/// values into dwl's header, and a test in zones.rs keeps the two equal. They -/// are held to the floor but not the contrast rule: the unzoned grey is light. +/// The compositor's own colours (unzoned, unknown zone, urgent), also in gen-zone-colours.py; +/// held to the floor but not the contrast rule, as the unzoned grey is light. pub const COMPOSITOR_COLOURS: [(&str, &str); 3] = [("unzoned", "#d8d8d8"), ("unknown", "#a2c9ff"), ("urgent", "#ffd000")]; @@ -118,8 +111,7 @@ impl Report { .filter(|c| c.severity == Severity::Critical) } - /// Whether the zone set should be refused. Only critical collisions refuse; - /// missing channels and low contrast are reported. + /// Whether to refuse the zone set: only critical collisions do, the rest is reported. pub fn is_fatal(&self) -> bool { self.critical().next().is_some() } diff --git a/compositor/zoneid/src/distinct/tests.rs b/compositor/zoneid/src/distinct/tests.rs index 09204466..caa63e54 100644 --- a/compositor/zoneid/src/distinct/tests.rs +++ b/compositor/zoneid/src/distinct/tests.rs @@ -37,7 +37,7 @@ fn pattern_rescues_nothing() { } #[test] -fn zone_in_a_compositor_colour() { +fn zone_in_compositor_colour() { for (name, hex) in COMPOSITOR_COLOURS { let r = analyze(&[id("x", hex)], Thresholds::default()); assert!(r.critical().any(|c| c.a == "x" && c.b == name), "{name}"); @@ -60,7 +60,7 @@ fn missing_channels() { } #[test] -fn low_contrast_reported_per_background() { +fn low_contrast_per_background() { // Near-black: invisible on the dark desktop, fine on the light one. let z = [id("ink", "#101010")]; let r = analyze(&z, Thresholds::default()); @@ -69,7 +69,7 @@ fn low_contrast_reported_per_background() { } #[test] -fn duplicate_glyphs_and_labels_reported() { +fn duplicate_glyphs_and_labels() { let a = ZoneIdentity::new("a", "#aa3333", None, Some("!"), Some("WORK")).unwrap(); let b = ZoneIdentity::new("b", "#2f6f9f", None, Some("!"), Some("w-o-r-k")).unwrap(); let r = analyze(&[a, b], Thresholds::default()); @@ -97,7 +97,7 @@ fn single_zone() { /// A known-bad palette must keep failing, worst under deuteranopia. #[test] fn known_bad_palette_fails() { - let shipped = [ + let palette = [ id("dev", "#b5651d"), id("net", "#2f6f9f"), id("personal", "#7a4fa3"), @@ -105,12 +105,8 @@ fn known_bad_palette_fails() { id("vault", "#c9a227"), id("work", "#3a7d44"), ]; - let r = analyze(&shipped, Thresholds::default()); - assert!( - r.is_fatal(), - "the original palette is expected to fail; if this now passes, the \ - metric has changed and needs looking at" - ); + let r = analyze(&palette, Thresholds::default()); + assert!(r.is_fatal(), "this palette must fail; a pass means the metric changed"); let pair = |a: &str, b: &str, v: Vision| { r.critical().any(|c| { @@ -124,7 +120,6 @@ fn known_bad_palette_fails() { ); assert!( pair("untrusted", "work", Vision::Deuteranopia), - "untrusted/work under deuteranopia is the pair that matters to the \ - threat model: sketchy links and your job, same window edge" + "untrusted/work should collide under deuteranopia too" ); } diff --git a/compositor/zoneid/src/identity.rs b/compositor/zoneid/src/identity.rs index 9718f0d6..0231d947 100755 --- a/compositor/zoneid/src/identity.rs +++ b/compositor/zoneid/src/identity.rs @@ -1,16 +1,12 @@ -//! The zone identity model: four channels and what a valid value is in each. -//! -//! Glyphs come from an allowlist, as syscalls do in the seccomp filter: a -//! denylist must foresee every bidi control, combining mark and confusable, -//! and missing one lets a zone file forge another zone's tag. Labels are -//! printable ASCII, which rules out bidi overrides and homographs at once. +//! The zone identity model: four channels and what a valid value is in each. Glyphs come from +//! an allowlist, as a denylist missing one confusable lets a zone forge another zone's tag; +//! labels are printable ASCII, which rules out bidi overrides and homographs. use std::fmt; use crate::color::{ParseHexError, Srgb}; -/// The border stroke a zone asks for. Validated to catch typos, but every -/// border is drawn solid, so zoneid gives it no weight. +/// The border stroke a zone asks for: validated, but every border is drawn solid. #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum Pattern { Solid, @@ -57,9 +53,8 @@ impl fmt::Display for Pattern { } } -/// Glyphs a zone may use. Each entry is in DejaVu and Liberation, has text -/// (not emoji) presentation, differs from every other entry at 12px, is no -/// mark, control, format character or space, and is unambiguous under NFKC. +/// Glyphs a zone may use: each is in DejaVu and Liberation, text (not emoji) presentation, +/// distinct from the others at 12px, no mark, control, format or space, unambiguous under NFKC. pub const GLYPH_ALLOWLIST: &[char] = &[ // Geometric shapes, U+25xx and U+26xx. '\u{25CF}', // ● BLACK CIRCLE @@ -130,21 +125,16 @@ impl fmt::Display for IdentityError { ), IdentityError::GlyphNotAllowed(c) => write!( f, - "glyph: U+{:04X} is not on the allowlist. Glyphs are allowlisted, \ - not filtered, so that font coverage and confusability are \ - reviewed rather than assumed; see GLYPH_ALLOWLIST", + "glyph: U+{:04X} is not on the allowlist; see GLYPH_ALLOWLIST", *c as u32 ), IdentityError::LabelEmpty => write!(f, "label: must not be empty"), IdentityError::LabelTooLong(n) => { write!(f, "label: at most 12 characters, got {n}") } - IdentityError::LabelNotAscii(c) => write!( - f, - "label: U+{:04X} is not printable ASCII. Labels are ASCII-only so \ - that bidi overrides cannot make one zone render as another", - *c as u32 - ), + IdentityError::LabelNotAscii(c) => { + write!(f, "label: U+{:04X} is not printable ASCII", *c as u32) + } IdentityError::LabelPadded => { write!(f, "label: must not begin or end with a space") } @@ -152,8 +142,7 @@ impl fmt::Display for IdentityError { } } -/// A zone's visual identity as configured. Missing non-colour channels stay -/// `None` rather than defaulting, because their absence is a finding. +/// A zone's identity as configured; a missing channel stays `None`, as its absence is a finding. #[derive(Clone, Debug)] pub struct ZoneIdentity { pub zone: String, @@ -195,7 +184,7 @@ impl ZoneIdentity { }) } - /// Channels this zone actually configures. + /// Channels this zone configures. pub fn present_channels(&self) -> Vec { let mut v = vec![Channel::Color]; if self.pattern.is_some() { @@ -210,8 +199,7 @@ impl ZoneIdentity { v } - /// Whether something other than colour on screen names this zone: the - /// glyph or label the chrome shows. A pattern is not drawn. + /// Whether the chrome can name this zone by glyph or label; a pattern is not drawn. pub fn has_non_color_channel(&self) -> bool { self.glyph.is_some() || self.label.is_some() } diff --git a/compositor/zoneid/src/identity/tests.rs b/compositor/zoneid/src/identity/tests.rs index 39b1949f..f6b13201 100644 --- a/compositor/zoneid/src/identity/tests.rs +++ b/compositor/zoneid/src/identity/tests.rs @@ -16,7 +16,7 @@ fn unknown_pattern_refused() { } #[test] -fn glyph_allowlist_has_no_duplicates() { +fn glyph_allowlist_unique() { let mut seen = Vec::new(); for &c in GLYPH_ALLOWLIST { assert!(!seen.contains(&c), "U+{:04X} listed twice", c as u32); @@ -25,13 +25,12 @@ fn glyph_allowlist_has_no_duplicates() { } #[test] -fn glyph_allowlist_contains_nothing_dangerous() { - // The allowlist is the whole defence, so check what is on it. +fn glyph_allowlist_safe() { + // The allowlist is the only defence, so check what is on it. for &c in GLYPH_ALLOWLIST { assert!(!c.is_control(), "U+{:04X} is a control character", c as u32); assert!(!c.is_whitespace(), "U+{:04X} is whitespace", c as u32); - /* Cf format characters (the bidi controls), and the variation - * selectors that switch a character to emoji rendering. */ + // Format characters (the bidi controls) and the selectors that switch to emoji rendering. let n = c as u32; assert!( !(0x200B..=0x200F).contains(&n), @@ -113,7 +112,7 @@ fn label_bounds() { } #[test] -fn label_key_folds_case_and_punctuation() { +fn label_key_folding() { let mk = |l: &str| { ZoneIdentity::new("z", "#aa3333", None, None, Some(l)) .unwrap() @@ -122,7 +121,7 @@ fn label_key_folds_case_and_punctuation() { }; assert_eq!(mk("WORK"), mk("work")); assert_eq!(mk("WORK"), mk("W-O-R-K")); - assert_ne!(mk("WORK"), mk("W0RK")); // zero vs O is a real difference + assert_ne!(mk("WORK"), mk("W0RK")); // zero and O stay different } #[test] diff --git a/compositor/zoneid/src/lib.rs b/compositor/zoneid/src/lib.rs index bc48c6ed..b31bf705 100755 --- a/compositor/zoneid/src/lib.rs +++ b/compositor/zoneid/src/lib.rs @@ -1,16 +1,12 @@ -//! Zone visual identity: can the user tell every two border colours the -//! compositor draws apart, under each of four vision models? -//! -//! The `zoneid` binary audits the zone files in CI and searches for palettes -//! that pass (docs/architecture.md). kryptikd only checks the shape of the -//! `[ui]` keys; neither it nor the proxy uses this crate. The check is a floor -//! on a colour-difference metric, not a guarantee against confusion. +//! Zone visual identity: can every two border colours the compositor draws be told apart +//! under each of four vision models? The `zoneid` binary audits the zone files in CI and +//! proposes palettes; kryptikd checks only the shape of the `[ui]` keys and does not use this. //! //! | Channel | Shown by | //! |---|---| //! | `color` | the whole window border, drawn by dwl | //! | `glyph`, `label` | the chrome, for the focused window | -//! | `pattern` | nothing yet: validated, given no weight | +//! | `pattern` | nothing: validated, given no weight | pub mod color; pub mod cvd; diff --git a/compositor/zoneid/src/palette.rs b/compositor/zoneid/src/palette.rs index c777bd5a..b9744b7a 100755 --- a/compositor/zoneid/src/palette.rs +++ b/compositor/zoneid/src/palette.rs @@ -1,9 +1,6 @@ -//! Palette search, which shows the floor can be met. -//! -//! A palette scores its smallest difference between any two colours under any -//! vision model (a minimum, as an average hides one colliding pair). The -//! search is farthest-point traversal, then steepest ascent from fixed -//! restarts: reproducible, and a lower bound rather than an optimum. +//! Palette search, to show the floor can be met. A palette scores its smallest difference under +//! any vision model (an average would hide one colliding pair). Farthest-point traversal, then +//! steepest ascent from fixed restarts: reproducible, and a lower bound, not an optimum. use crate::color::{contrast_ratio, ciede2000, Lab, Srgb}; use crate::cvd::{simulate, Vision}; @@ -26,16 +23,12 @@ struct Candidate { /// Search parameters, for measuring which constraint binds. #[derive(Clone, Copy, Debug)] pub struct SearchOptions { - /// Minimum contrast against both backgrounds. 1.0 drops the constraint, - /// which models a border drawn with a contrasting keyline. + /// Minimum contrast against both backgrounds; 1.0 models a border with a contrasting keyline. pub min_contrast: f64, - /// Sampling step through each sRGB axis for the coarse search. 17 gives - /// 16 levels per channel. + /// Sampling step along each sRGB axis for the coarse search; 17 gives 16 levels per channel. pub step: u32, - /// Sampling step for refining each colour around the coarse result; 0 - /// disables it. In a release build the coarse grid alone reaches 13.98 at - /// step 17 (15.42 at step 6, in 3.1 s); step 17 refined every 3 reaches - /// 15.70 in 1.8 s. + /// Refinement step around the coarse result, 0 for none. In a release build step 17 alone + /// reaches 13.98 (step 6: 15.42 in 3.1 s); refined every 3 it reaches 15.70 in 1.8 s. pub refine: u32, } @@ -53,8 +46,7 @@ impl Default for SearchOptions { const REFINE_RADIUS: i32 = 17; impl Candidate { - /// The candidate for an 8-bit sRGB triple, or `None` if it fails the - /// contrast floor against any background. + /// The candidate for an 8-bit sRGB triple; `None` if out of range or below the contrast floor. fn from_rgb8(r: i32, g: i32, b: i32, backgrounds: &[Srgb], min_contrast: f64) -> Option { if !(0..=255).contains(&r) || !(0..=255).contains(&g) || !(0..=255).contains(&b) { return None; @@ -116,8 +108,7 @@ fn distance(a: &Labs, b: &Labs) -> f64 { worst } -/// `start` lowered to `c`'s distance from each of `others`, stopping early -/// once it is at or below `floor` (the best score so far). +/// `start` lowered to `c`'s distance from each of `others`; stops once at or below `floor`. fn nearest<'a>(c: &Labs, others: impl IntoIterator, start: f64, floor: f64) -> f64 { let mut near = start; for o in others { @@ -129,8 +120,7 @@ fn nearest<'a>(c: &Labs, others: impl IntoIterator, start: f64, near } -/// A palette's score: its smallest difference, between two members or -/// between a member and a compositor colour. +/// A palette's score: the smallest difference among its members and the compositor's colours. fn score(pal: &[Labs], fixed: &[Labs]) -> f64 { let mut s = f64::INFINITY; for (i, a) in pal.iter().enumerate() { @@ -139,17 +129,15 @@ fn score(pal: &[Labs], fixed: &[Labs]) -> f64 { s } -/// The palette without member `slot`, and its score, so scoring a replacement -/// is `nearest(colour, others, rest, ..)`: one distance per member. +/// The palette without `slot`, and its score, so a replacement costs one distance per member. fn without(pal: &[Labs], slot: usize, fixed: &[Labs]) -> (Vec, f64) { let others: Vec = pal.iter().enumerate().filter(|&(i, _)| i != slot).map(|(_, l)| *l).collect(); let rest = score(&others, fixed); (others, rest) } -/// Move each colour to the best point within `REFINE_RADIUS`, sampled every -/// `fine` levels, until nothing improves. Candidates are made on demand; fixed -/// order and strict improvement keep it deterministic. +/// Move each colour to the best point within `REFINE_RADIUS`, every `fine` levels, until nothing +/// improves; fixed order and strict improvement keep it deterministic. fn refine(pal: &mut [Candidate], fixed: &[Labs], min_contrast: f64, fine: u32) { if fine == 0 { return; @@ -204,8 +192,7 @@ pub struct Proposal { pub worst_per_vision: Vec<(Vision, f64)>, } -/// Search for `n` maximally distinguishable colours. `None` if `n` is 0 or -/// fewer than `n` colours meet the contrast requirement. +/// Search for `n` distinguishable colours; `None` if `n` is 0 or too few clear the contrast floor. pub fn propose(n: usize) -> Option { propose_with(n, SearchOptions::default()) } @@ -231,9 +218,8 @@ pub fn propose_with(n: usize, opts: SearchOptions) -> Option { // Membership of `chosen` by candidate index, asked once per candidate below. let mut in_set = vec![false; cands.len()]; in_set[seed] = true; - /* Farthest-point traversal: take the candidate furthest from - * everything picked and from the compositor's colours. `near[c]` is - * that distance, kept current by one pass against the newest pick. */ + /* Farthest-point traversal: `near[c]` is c's distance to everything picked and to the + * compositor's colours, kept current by one pass against the newest pick. */ let mut near: Vec = cands.iter().zip(&to_fixed).map(|(c, &f)| f.min(distance(&c.lab, &cands[seed].lab))).collect(); while chosen.len() < n { diff --git a/compositor/zoneid/src/palette/tests.rs b/compositor/zoneid/src/palette/tests.rs index 510f7b5b..f6c47122 100644 --- a/compositor/zoneid/src/palette/tests.rs +++ b/compositor/zoneid/src/palette/tests.rs @@ -62,14 +62,13 @@ fn six_colour_palette_passes() { assert!(p.score >= MIN_DELTA_E + 0.5, "default search reached only {:.2}", p.score); } -/// The coarse grid alone does not reach the floor (13.98 at step 17); -/// refinement does. +/// The coarse grid alone stays under the floor (13.98 at step 17); refinement reaches it. #[test] fn refinement_reaches_floor() { let coarse = propose_with(6, SearchOptions { refine: 0, ..SearchOptions::default() }).unwrap(); let refined = default_six(); assert!(coarse.score < refined.score, "coarse {:.2} vs refined {:.2}", coarse.score, refined.score); - assert!(coarse.score < MIN_DELTA_E, "the coarse grid now passes on its own ({:.2}); update this comment and the doc on SearchOptions::refine", coarse.score); + assert!(coarse.score < MIN_DELTA_E, "the coarse grid passes alone ({:.2}): update the docs here and on SearchOptions::refine", coarse.score); } #[test] diff --git a/compositor/zoneid/src/toml.rs b/compositor/zoneid/src/toml.rs index 320af47e..cdf35997 100755 --- a/compositor/zoneid/src/toml.rs +++ b/compositor/zoneid/src/toml.rs @@ -1,10 +1,5 @@ -//! A small TOML reader for what zone files use, hand-rolled as ADR-010 argues -//! for kryptikd: comments, `[section]` headers, and `key = value` with a basic, -//! literal or bare value, all kept as text. -//! -//! Anything else (arrays, inline tables, dotted keys, array-of-tables, -//! multi-line strings) is an error, never skipped. Comments are stripped by the -//! scanner that tracks quotes, so `"#aa3333"` keeps its `#`. +//! The TOML subset zone files use (ADR-010): comments, `[section]` headers, and `key = value` +//! with a basic, literal or bare value, kept as text. Anything else is an error, never skipped. use std::fmt; @@ -44,20 +39,13 @@ impl fmt::Display for TomlError { TomlErrorKind::TrailingGarbage(s) => { write!(f, "unexpected text after value: {s:?}") } - TomlErrorKind::Unsupported(what) => write!( - f, - "{what} is not supported by this reader. It is refused rather \ - than skipped so that a zone file using it is never read as \ - something other than what it says" - ), + TomlErrorKind::Unsupported(what) => write!(f, "{what} is not supported"), TomlErrorKind::BadEscape(c) => write!(f, "unknown string escape \\{c}"), } } } -/// Sections in file order, each a list of key/value pairs. Read as kryptikd -/// reads it (a repeated `[section]` continues, a repeated key is an error), so -/// zoneid audits the colour kryptikd enforces. +/// Sections in file order, read as kryptikd reads them, so the audited colour is the enforced one. #[derive(Debug, Default, Clone)] pub struct Document { pub sections: Vec
, @@ -163,7 +151,6 @@ pub fn parse(input: &str) -> Result { fn strip_comment(line: &str) -> Result<&str, TomlErrorKind> { let bytes = line.as_bytes(); let mut i = 0; - // The quote character, if any, this position is inside. let mut quote: Option = None; while i < bytes.len() { @@ -215,8 +202,7 @@ fn parse_value(v: &str) -> Result { Ok(v.to_string()) } -/// Read a basic string body (after the opening quote). Returns the unescaped -/// value and how many bytes of `s` were consumed including the closing quote. +/// Unescape a basic string body after its opening quote; returns it and the bytes consumed. fn read_basic_string(s: &str) -> Result<(String, usize), TomlErrorKind> { let mut out = String::new(); let mut it = s.char_indices(); diff --git a/compositor/zoneid/src/toml/tests.rs b/compositor/zoneid/src/toml/tests.rs index 4d23887d..e0be02cc 100644 --- a/compositor/zoneid/src/toml/tests.rs +++ b/compositor/zoneid/src/toml/tests.rs @@ -1,7 +1,7 @@ use super::*; #[test] -fn hash_in_string_is_not_comment() { +fn quoted_hash_kept() { let d = parse("[ui]\nborder_color = \"#aa3333\"\n").unwrap(); assert_eq!(d.get("ui", "border_color"), Some("#aa3333")); } @@ -13,7 +13,7 @@ fn comment_after_colour() { } #[test] -fn full_line_comments_and_blank_lines() { +fn comments_and_blank_lines() { let d = parse("# a zone\n\n[zone]\nname = \"work\"\n").unwrap(); assert_eq!(d.get("zone", "name"), Some("work")); } @@ -60,7 +60,7 @@ fn unsupported_constructs_are_errors() { } #[test] -fn unterminated_string_is_an_error() { +fn unterminated_string_refused() { let e = parse("[a]\nv = \"oops\n").unwrap_err(); assert_eq!(e.kind, TomlErrorKind::UnterminatedString); } @@ -85,9 +85,9 @@ fn trailing_garbage_refused() { assert!(matches!(e.kind, TomlErrorKind::TrailingGarbage(_))); } -/// A real zone file, verbatim. +/// A whole zone file, in the shipped layout. #[test] -fn parses_real_zone_file() { +fn parses_zone_file() { // r##: the file contains `"#`. let src = r##" # untrusted - for opening things you do not trust. diff --git a/compositor/zoneid/src/zones.rs b/compositor/zoneid/src/zones.rs index 7bc44a4f..21a81c87 100644 --- a/compositor/zoneid/src/zones.rs +++ b/compositor/zoneid/src/zones.rs @@ -1,13 +1,11 @@ -//! Zone identities from a zone directory (as kryptikd installs under -//! /etc/kryptik/zones); only `[zone] name` and the `[ui]` channels are read. +//! Zone identities from a zone directory; only `[zone] name` and the `[ui]` channels are read. use std::path::{Path, PathBuf}; use crate::identity::ZoneIdentity; use crate::toml; -/// Read every `*.toml` in `dir` as a zone definition, skipping any without a -/// `[ui] border_color`: a zone with no colour is not a colour collision. +/// Load every `*.toml` in `dir`, skipping zones with no `[ui] border_color` to collide. pub fn load_zones(dir: &Path) -> Result, String> { let entries = std::fs::read_dir(dir) .map_err(|e| format!("cannot read {}: {e}", dir.display()))?; @@ -56,7 +54,7 @@ pub fn load_zones(dir: &Path) -> Result, String> { Ok(out) } -/// The zone directory this crate ships beside, for its own tests. +/// The zone directory beside this crate in the source tree. pub fn shipped_zone_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../compartments/zones") } diff --git a/compositor/zoneid/src/zones/tests.rs b/compositor/zoneid/src/zones/tests.rs index 3f8102ff..d0395b81 100644 --- a/compositor/zoneid/src/zones/tests.rs +++ b/compositor/zoneid/src/zones/tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::distinct::{analyze, Thresholds, COMPOSITOR_COLOURS, MIN_DELTA_E}; use crate::identity::Channel; -/// dwl's colour header holds exactly the audited colours, so dwl draws none unchecked. +/// dwl's colour header holds the audited colours and no others, so dwl draws none unchecked. #[test] fn header_colours_are_audited() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../build/desktop/zone-colours.h"); @@ -24,8 +24,7 @@ fn header_colours_are_audited() { assert_eq!(drawn, audited); } -/// Every shipped pair clears the floor under every vision model, with no -/// other finding, and every zone carries all four channels. +/// The shipped zones pass with no finding at all, and each carries all four channels. #[test] fn shipped_zones_pass_invariant() { let zones = load_zones(&shipped_zone_dir()).expect("the shipped zone files parse"); diff --git a/tools/desktop/gen-wl-protocol.py b/tools/desktop/gen-wl-protocol.py index fd975ab5..a13a48cc 100755 --- a/tools/desktop/gen-wl-protocol.py +++ b/tools/desktop/gen-wl-protocol.py @@ -8,14 +8,13 @@ SIMPLE = {"int": "Arg::Int", "uint": "Arg::Uint", "fixed": "Arg::Fixed", "array": "Arg::Array", "fd": "Arg::Fd"} def args_of(msg): - """The message's arguments as Rust, in wire order, and how many are descriptors. - A type this does not know stops the generator.""" + """The message's arguments as Rust, in wire order, and its fd count; an unknown type raises.""" args, fds = [], 0 for a in msg.findall("arg"): t = a.get("type"); nullable = "true" if a.get("allow-null") == "true" else "false" if t == "new_id": iface = a.get("interface") - # No interface (wl_registry.bind): the id follows the interface name and version on the wire. + # No interface (wl_registry.bind): the client names it on the wire, before the id. args.append(f'Arg::NewId {{ iface: Some("{iface}") }}' if iface else "Arg::NewId { iface: None }") elif t in ("string", "object"): args.append(f"Arg::{t.capitalize()} {{ nullable: {nullable} }}") From cc86d1f7c8e2cc886d60f328a3d25863cc526b8e Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 02:10:41 -0700 Subject: [PATCH 04/14] Cut the compartment suites' comments to one line launcher.sh, serve.sh and adversarial.sh lose their stacked comment blocks, and the zone files and seccomp policies their multi-line notes. Stale text is corrected: the ephemeral size comment contradicted zone.rs, the vault note overclaimed, the K skip names zones-test.sh rather than a developer VM that no longer exists, and F4c's message gives the 30 s it waits. A dead assignment in E4a goes. Every check ID, every line the CI workflow or zones-test.sh greps, and every zone file key and value are unchanged. cli.sh and the probes are left out: open pull requests change them. --- compartments/tests/adversarial.sh | 62 +-- compartments/tests/launcher.sh | 472 ++++++-------------- compartments/tests/serve.sh | 65 ++- compartments/zones/dev.toml | 11 +- compartments/zones/net.toml | 22 +- compartments/zones/personal.toml | 8 +- compartments/zones/policy/dev.seccomp | 3 +- compartments/zones/policy/net.seccomp | 8 +- compartments/zones/policy/personal.seccomp | 3 +- compartments/zones/policy/untrusted.seccomp | 3 +- compartments/zones/policy/vault.seccomp | 3 +- compartments/zones/policy/work.seccomp | 3 +- compartments/zones/untrusted.toml | 14 +- compartments/zones/vault.toml | 11 +- compartments/zones/work.toml | 8 +- 15 files changed, 217 insertions(+), 479 deletions(-) diff --git a/compartments/tests/adversarial.sh b/compartments/tests/adversarial.sh index 98fdaba5..4dcdd669 100755 --- a/compartments/tests/adversarial.sh +++ b/compartments/tests/adversarial.sh @@ -1,13 +1,6 @@ #!/usr/bin/env bash -# The isolation exit test: as root inside an `untrusted` zone it must be -# impossible to 1. list another zone's processes, 2. read another zone's files, -# 3. reach the physical NIC, 4. read the vault (the four in compartments/README.md) -# or 5. reach dangerous syscalls, since a kernel LPE compromises every zone at -# once (docs/threat-model.md). -# -# Runs on any host with user namespaces: unshare(1) with kryptikd's namespace -# set (isolate.rs), and kryptikd itself for Landlock and seccomp. Exits 0 only -# when every check passes. +# Isolation exit test: root in `untrusted` attacks other zones, the NIC, the vault and the kernel. +# Needs user namespaces; uses unshare(1) and kryptikd. Exits 0 only when every check passes. set -uo pipefail @@ -31,8 +24,7 @@ cleanup() { } trap cleanup EXIT -# kryptikd's namespace set for a non-NIC zone, as in isolate.rs::namespace_flags; -# the consistency check below compares them. +# isolate.rs::namespace_flags for a non-NIC zone; the consistency check compares the two. ZONE_UNSHARE=(--user --map-root-user --pid --mount --ipc --uts --net --fork) printf '%sKryptik isolation exit test — adversarial%s\n' "$C_BLU" "$C_RST" @@ -53,15 +45,8 @@ if ! unshare --user --map-root-user true 2>/dev/null; then # Ubuntu 24.04+ and some hardened kernels block this by default. restrict="$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo "")" if [[ "$restrict" == "1" ]]; then - info "" - info "kernel.apparmor_restrict_unprivileged_userns=1 on this host." - info "Ubuntu 24.04+ sets this because unprivileged userns is a known" - info "LPE vector. To run this test:" + info "kernel.apparmor_restrict_unprivileged_userns=1 on this host. To run this test:" info " sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0" - info "" - info "Kryptik agrees with the restriction, incidentally: it sets" - info "CONFIG_USER_NS_UNPRIVILEGED=n, because kryptikd creates zones" - info "with privilege and nothing inside a zone needs to." fi max_ns="$(sysctl -n user.max_user_namespaces 2>/dev/null || echo "")" if [[ "$max_ns" == "0" ]]; then @@ -107,8 +92,7 @@ info "planted: $(basename "$OTHER_ZONE_SECRET"), $(basename "$VAULT_SECRET")" head_ "Requirement 1 — cannot list processes in another zone" -# Without --mount-proc the zone keeps the host's /proc, pid namespace or not. -# It may see its own processes, just no one else's. +# Without --mount-proc the zone keeps the host's /proc, whatever its pid namespace. visible="$(unshare "${ZONE_UNSHARE[@]}" --mount-proc bash -c 'ls /proc | grep -c "^[0-9]*$"' 2>/dev/null)" # Every /proc entry starting with a digit is a pid; a glob starts no process. host_pids=0 @@ -119,7 +103,6 @@ else fail "zone sees ${visible:-?} pids (host has ${host_pids}) - pid namespace is not isolating" fi -# The zone's pid 1 must be its own init, not the host's. zone_init="$(unshare "${ZONE_UNSHARE[@]}" --mount-proc bash -c 'cat /proc/1/comm' 2>/dev/null)" host_init="$(cat /proc/1/comm 2>/dev/null)" if [[ -n "$zone_init" ]] && [[ "$zone_init" != "$host_init" ]]; then @@ -145,9 +128,7 @@ fi head_ "Requirement 3 — cannot reach the physical NIC" -# Devices the kernel puts in every netns, which the zone neither asked for nor -# can remove; the same list as launcher.sh. The netns is probed directly: /sys -# disagrees with it until remounted (below). +# Devices every netns gets and a zone cannot remove; the same list as launcher.sh. KERNEL_FALLBACK_IFS="sit0" ifaces="$(unshare "${ZONE_UNSHARE[@]}" bash -c 'ip -o link show 2>/dev/null | sed "s/^[0-9]*: //; s/[:@].*//"' 2>/dev/null | tr '\n' ' ' | xargs)" @@ -164,9 +145,7 @@ else pass "zone network namespace contains only loopback (found: ${ifaces})" fi -# A fallback device that can be deleted inside the netns is for kryptikd to -# delete, not for the kernel config. Each unshare makes a fresh netns, so the -# delete and its listing share one. +# A deletable fallback device is kryptikd's job, not the kernel's; delete and list in one netns. for d in $adv_fallback; do delmsg="$(unshare "${ZONE_UNSHARE[@]}" bash -c "ip link del $d 2>&1 | head -1" 2>/dev/null)" after="$(unshare "${ZONE_UNSHARE[@]}" bash -c "ip link del $d >/dev/null 2>&1; ip -o link show 2>/dev/null | sed 's/^[0-9]*: //; s/[:@].*//'" 2>/dev/null | tr '\n' ' ' | xargs)" @@ -179,8 +158,7 @@ for d in $adv_fallback; do fi done -# Without a sysfs remount the zone reads the host's /sys/class/net: it cannot -# use those interfaces, but it learns the topology. +# Without a sysfs remount the zone cannot use the host's interfaces but learns their names. sys_before="$(unshare "${ZONE_UNSHARE[@]}" bash -c 'ls /sys/class/net 2>/dev/null' 2>/dev/null | tr '\n' ' ' | xargs)" sys_after="$(unshare "${ZONE_UNSHARE[@]}" bash -c 'mount -t sysfs sysfs /sys 2>/dev/null; ls /sys/class/net 2>/dev/null' 2>/dev/null | tr '\n' ' ' | xargs)" # Only host devices fail it; lo and the fallback devices are expected. @@ -209,15 +187,13 @@ fi head_ "Requirement 2 — cannot read another zone's filesystem" -# Negative control: a mount namespace copies the mount table and gives no -# private view of the files, so namespaces alone leave other zones readable. +# Negative control: a mount namespace only copies the mount table, so it hides no file. if unshare "${ZONE_UNSHARE[@]}" cat "$OTHER_ZONE_SECRET" >/dev/null 2>&1; then info "control: with namespaces ALONE the zone can read ${OTHER_ZONE_SECRET##*/}" info " a mount namespace is not filesystem isolation" fi -# Landlock, applied by kryptikd itself. tools/run-tests.sh exports KRYPTIKD; -# run directly, the suite uses the default build paths. +# tools/run-tests.sh exports KRYPTIKD; run directly, the suite looks in the build tree. if [[ -z "${KRYPTIKD:-}" || ! -x "${KRYPTIKD:-}" ]]; then KRYPTIKD="$(dirname "${BASH_SOURCE[0]}")/../kryptikd/target/debug/kryptikd" [[ -x "$KRYPTIKD" ]] || KRYPTIKD="$(dirname "${BASH_SOURCE[0]}")/../kryptikd/target/release/kryptikd" @@ -231,8 +207,7 @@ else mkdir -p "$ZONE_ROOT" echo "this zone's own data" > "${ZONE_ROOT}/mine.txt" - # Control: the zone must still read its own files, or "blocked" below - # proves nothing. + # Control: the zone must still read its own files, or "blocked" below proves nothing. "$KRYPTIKD" confine-test "$ZONE_ROOT" "${ZONE_ROOT}/mine.txt" >/dev/null 2>&1 own_rc=$? if [[ "$own_rc" -eq 0 ]]; then @@ -264,15 +239,13 @@ else 0) fail "confined zone read vault content - Landlock did not confine" ;; *) fail "vault confinement test errored (rc=${vault_rc})" ;; esac - info "In a real Kryptik system the vault has a second, independent control:" - info "its LUKS2 volume is not unlocked at all while other zones run, so" - info "there is no plaintext to reach even if Landlock were bypassed." + info "on Kryptik the vault's LUKS2 volume is also closed whenever the vault zone is not running" fi # --- requirement 5: kernel attack surface ----------------------------------- -# All zones share one kernel; seccomp raises the cost of finding an LPE in it. head_ "Requirement 5 — cannot reach the kernel's dangerous syscalls" +# All zones share one kernel; seccomp raises the cost of finding an LPE in it. if [[ ! -x "${KRYPTIKD:-}" ]]; then fail "kryptikd not built - cannot test seccomp" @@ -288,7 +261,7 @@ else fail "the filter blocks syscalls a zone needs - over-restrictive" fi - # setns alone would step into another zone's namespaces, defeating 1-4. + # setns alone would step into another zone's namespaces. leaked=0 for sc in setns ptrace unshare mount bpf perf_event_open userfaultfd \ keyctl init_module kexec_load process_vm_readv pivot_root chroot; do @@ -330,7 +303,8 @@ if [[ -f "$ISOLATE_RS" ]]; then # The seccomp filter must default-deny, so it needs a kill action. SECCOMP_RS="$(dirname "${BASH_SOURCE[0]}")/../kryptikd/src/seccomp.rs" if [[ -f "$SECCOMP_RS" ]]; then - grep -q "SECCOMP_RET_KILL_PROCESS" "$SECCOMP_RS" || { echo " seccomp filter has no kill action"; missing=1; } + grep -q "SECCOMP_RET_KILL_PROCESS" "$SECCOMP_RS" \ + || { echo " seccomp filter has no kill action"; missing=1; } fi if [[ "$missing" -eq 0 ]]; then pass "kryptikd declares every namespace this test exercises" @@ -350,9 +324,7 @@ printf ' failed: %d\n' "$FAIL" if [[ "$FAIL" -gt 0 ]]; then printf '\n%sUnmet requirements:%s\n' "$C_YEL" "$C_RST" printf ' - %s\n' "${FAILED[@]}" - printf '\n%sThe compartment layer is NOT complete.%s\n' "$C_YEL" "$C_RST" - printf 'This is the expected result while the compartment layer is being\n' - printf 'built. The failures above are the specification for what remains.\n' + printf '\n%sNot every isolation exit requirement holds.%s\n' "$C_YEL" "$C_RST" exit 1 fi diff --git a/compartments/tests/launcher.sh b/compartments/tests/launcher.sh index 7994867d..193b1dc6 100755 --- a/compartments/tests/launcher.sh +++ b/compartments/tests/launcher.sh @@ -1,18 +1,6 @@ #!/usr/bin/env bash -# The real-launcher suite: every check goes through `kryptikd run ZONE --zones -# DIR --rootfs DIR -- COMMAND`; adversarial.sh tests the primitives alone. -# -# A zone that dies in setup leaks nothing either, so each isolation check has -# the zone print a launch sentinel before its probe result, and a missing -# sentinel fails as "did not launch" (want_launch, probe). Positive controls -# first show that the target is reachable from outside. -# -# [unpriv] groups run as any user on a Linux host with the kernel features; -# [vm] groups need root in a disposable VM and are otherwise NOT RUN, which is -# not a pass. Launches are time-bounded and all state lives under one mktemp -d. -# -# Exit status: 1 if any check failed, 2 if the suite could not start. A run -# with checks NOT RUN exits 0 but reports gaps, which is not a release pass. +# Launcher suite: every check runs through `kryptikd run`; adversarial.sh tests the primitives. +# Exit 1 if a check failed, 2 if the suite could not start; a NOT RUN check is a gap, not a pass. set -uo pipefail @@ -43,15 +31,13 @@ if [[ ! -x "$KRYPTIKD" ]]; then exit 2 fi -# A kryptikd older than its sources makes every check fail as "did not launch" -# with nothing pointing at the binary. Only in a build tree (Cargo.toml): an -# image ships isolate.rs for adversarial.sh, newer than its installed binary. +# A stale binary fails every check as "did not launch"; only checked in a build tree. if [[ -z "${KRYPTIK_SKIP_STALE_CHECK:-}" && -f "$REPO/compartments/kryptikd/Cargo.toml" ]]; then newer="$(find "$REPO/compartments/kryptikd/src" "$REPO/compartments/kryptikd/Cargo.toml" \ -newer "$KRYPTIKD" 2>/dev/null | head -3)" if [[ -n "$newer" ]]; then printf '%sSTALE BINARY%s: %s is older than its sources.\n' "$C_YEL" "$C_RST" "$KRYPTIKD" - printf 'Every check would report "did not launch" and none of them would say why.\n' + printf 'Every check would fail as "did not launch".\n' printf 'Newer than the binary:\n' printf ' %s\n' $newer printf '\nRun: (cd %s/compartments/kryptikd && cargo build)\n' "$REPO" @@ -80,7 +66,7 @@ cleanup() { for p in "${BG_PIDS[@]:-}"; do [[ -n "$p" ]] && kill -9 "$p" 2>/dev/null done - # Zone mounts lived in the zones' own mount namespaces; only directories remain. + # Zone mounts live in the zones' own mount namespaces, so only directories remain. rm -rf "$WORK" 2>/dev/null } trap cleanup EXIT @@ -90,8 +76,7 @@ trap cleanup EXIT # A synthetic canary, unmistakable in a grep. CANARY="KRYPTIK_CANARY_a7f3c091_DO_NOT_LEAK" -# Stand-in host configuration a zone must not see. World-readable, so a zone -# that cannot read it is contained, not merely refused by file permissions. +# World-readable, so a zone that cannot read it is contained, not merely denied by mode. printf 'token = %s\n' "$CANARY" > "$HOSTFIX/hostconfig.conf" chmod 0644 "$HOSTFIX/hostconfig.conf" @@ -104,47 +89,39 @@ mkzone() { # name mode colour [extra-network-lines] [storage-mode] printf '[storage]\nmode = "%s"\n' "$storage" # storage.size is required for ephemeral and refused for encrypted. [[ "$storage" == "ephemeral" ]] && printf 'size = "64M"\n' - # An encrypted fixture's volume lives under $WORK (F4 creates it as - # root), never in /var/lib/kryptik. + # An encrypted fixture's volume: under $WORK (F4 creates it as root), not /var/lib/kryptik. [[ "$storage" == "encrypted" ]] && printf 'volume = "%s/volumes/%s.luks"\n' "$WORK" "$name" printf '[ui]\nborder_color = "%s"\n' "$colour" } > "$ZONES/$name.toml" } -# A zone set needs exactly one NIC zone, hence `carrier`. Only group NETR, -# in a disposable VM, starts it. +# A zone set needs exactly one NIC zone, hence `carrier`; only group NETR starts it. mkzone alpha none "#111111" mkzone beta none "#222222" mkzone carrier nic "#333333" mkzone sealed none "#444444" '' encrypted mkzone wiped none "#555555" '' ephemeral -# The persistent fixture. `sealed` needs a volume and a passphrase (group F), -# so checks that only need a kept directory use this one. +# Persistent but unencrypted, for checks that only need a kept directory. mkzone keeper none "#4a4a4a" '' persistent # K7: a zone whose data directory is owned by someone else. mkzone stranger none "#666666" mkzone lczone none "#0a0a0a" -# Group M fixtures, and `roomy`: the same shape with limits nothing should hit, -# as the positive control. mkzone_limited() { # name colour memory pids { printf '[zone]\nname = "%s"\ndescription = "launcher-suite limit fixture"\n' "$1" printf '[network]\nmode = "none"\n' - # Under every memory_max here (memcapped's is 48M): a tmpfs larger than - # the memory limit is refused, since its pages are charged to it. + # Below memcapped's 48M, since a tmpfs larger than the memory limit is refused. printf '[storage]\nmode = "ephemeral"\nsize = "32M"\n' printf '[limits]\nmemory_max = "%s"\npids_max = %s\n' "$3" "$4" printf '[ui]\nborder_color = "%s"\n' "$2" } > "$ZONES/$1.toml" } -# One limit per fixture: under a 48M memory limit the OOM killer takes the zone -# before a fork loop could reach a pids limit. +# One limit per fixture: at 48M the OOM killer would end a fork loop before a pids limit. mkzone_limited memcapped "#777777" "48M" 200 # memory is the variable mkzone_limited pidcapped "#999999" "512M" 32 # pids is the variable mkzone_limited roomy "#888888" "512M" 200 # neither: the positive control -# cpu_max is the variable: a quarter of one CPU, against roomy's unlimited -# time. Its own colour: kryptikd refuses two zones that share one. +# cpu_max is the variable, a quarter of one CPU; kryptikd refuses a colour two zones share. { printf '[zone]\nname = "cpucapped"\ndescription = "launcher-suite limit fixture"\n' printf '[network]\nmode = "none"\n[storage]\nmode = "ephemeral"\nsize = "32M"\n' @@ -154,8 +131,7 @@ mkzone_limited roomy "#888888" "512M" 200 # neither: the positive control mkzone throttled none "#aaaaaa" '' encrypted printf '[limits]\nio_max = "8M"\n' >> "$ZONES/throttled.toml" -# A root launch must map the zone to an unprivileged identity: kryptikd refuses -# to make zone root host uid 0. +# kryptikd refuses to make zone root host uid 0, so a root launch maps it elsewhere. ZONE_UID=100000 ZONE_GID=100000 IDENTITY=() @@ -168,8 +144,7 @@ else info "running unprivileged as uid $EUID: zones map to this uid" fi -# mktemp -d makes 0700, but a privileged zone's setup drops to the mapped uid -# before it opens its data directory. +# mktemp -d makes 0700, but a privileged zone's setup opens its data as the mapped uid. chmod 0755 "$WORK" "$ZONES" "$ROOTFS" "$HOSTFIX" if (( PRIVILEGED == 1 )); then # kryptikd refuses a data directory not owned by the mapped uid. @@ -181,14 +156,12 @@ ZARGS=(--zones "$ZONES" --rootfs "$ROOTFS" "${IDENTITY[@]}") # --- launch helpers --------------------------------------------------------- -# zrun ZONE -- CMD...: run in a zone with KRYPTIK_EXPERIMENTAL=1; output to -# ZOUT, exit code to ZRC. zrun_raw: without the override, for refusal checks. +# zrun ZONE -- CMD...: run with KRYPTIK_EXPERIMENTAL=1; output in ZOUT, status in ZRC. ZOUT=""; ZRC=0 zrun_with() { # zrun_with with|without ZONE -- CMD... local mode="$1" zone="$2"; shift 2 [[ "${1:-}" == "--" ]] && shift - # A zone that declares [identity] refuses --zone-uid/--zone-gid: the file - # decides who owns its data (tools/kryptik does the same). + # A zone with [identity] refuses --zone-uid/--zone-gid: the file decides who owns its data. local -a za=("${ZARGS[@]}") if (( ${#IDENTITY[@]} )) && grep -q '^\[identity\]' "$ZONES/$zone.toml" 2>/dev/null; then za=(--zones "$ZONES" --rootfs "$ROOTFS") @@ -206,22 +179,21 @@ zrun_with() { # zrun_with with|without ZONE -- CMD... return 0 } zrun() { zrun_with with "$@"; } -zrun_raw() { zrun_with without "$@"; } +zrun_raw() { zrun_with without "$@"; } # without the override, for refusal checks -# The launch sentinel, printed first by every zone command. +# Printed first by every zone command, since a zone that dies in setup also leaks nothing. LAUNCHED="ZONE_LAUNCH_OK" -# kryptikd's zone registry, derived as base() does; groups K and LC read it. +# kryptikd's zone registry, derived as registry.rs's base() does. REG="${XDG_RUNTIME_DIR:-/tmp/kryptik-$(id -u)}/kryptik/zones" [[ "$EUID" -eq 0 ]] && REG=/run/kryptik/zones -# Preflight: a second `run` of a running zone is refused, so one leftover -# launcher would fail dozens of checks. Only this suite's names count: the -# registry is per uid, shared with every other checkout on the machine. +# A leftover launcher would fail dozens of checks: a second `run` of a running zone is refused. mine="$(cd "$ZONES" && ls ./*.toml 2>/dev/null | sed 's|^\./||; s|\.toml$||' | tr '\n' '|')" mine="${mine%|}" if running="$("$KRYPTIKD" list --running 2>/dev/null)"; then if [[ -n "$mine" ]]; then + # Only this suite's names: the registry is per uid, shared with every other checkout. running="$(printf '%s\n' "$running" | grep -E "^(${mine})[[:space:]]" || true)" fi if [[ "$running" != *"no zones are running"* && -n "${running//[[:space:]]/}" ]]; then @@ -229,10 +201,8 @@ if running="$("$KRYPTIKD" list --running 2>/dev/null)"; then printf '%s\n' "$running" | sed 's/^/ /' >&2 cat >&2 <<'PRE' -This suite reuses these zone names, and the registry refuses a second launch of -a name that is already running - so it would report dozens of failures that are -all this one fact. Stop the launcher (or run `kryptikd gc` if it is dead) and -try again. +This suite reuses these zone names, and a running zone refuses a second launch. +Stop the launcher (or run `kryptikd gc` if it is dead) and try again. PRE exit 2 fi @@ -245,9 +215,7 @@ want_launch() { return 0 fi - # A launch the target kernel refuses by design is NOT RUN, not failed: - # kryptikd ignores KRYPTIK_EXPERIMENTAL for a root launch on a kernel that - # restricts unprivileged user namespaces (docs/design/privileged-launch.md). + # As root on a kernel restricting user namespaces the override is ignored: NOT RUN, not failed. if [[ "$ZOUT" == *"KRYPTIK_EXPERIMENTAL is ignored"* ]]; then skip "$desc [needs a zone whose guarantees are unmet; this kernel correctly refuses to start one]" return 1 @@ -286,8 +254,8 @@ PRO="echo $LAUNCHED;" # ============================================================================ head_ "A. The launcher actually runs programs [unpriv]" # ============================================================================ -# A zone that runs nothing isolates perfectly, so this group comes first. +# First, since a zone that runs nothing isolates perfectly. zrun alpha -- /bin/echo "$LAUNCHED" if want_launch "A1 a program executes inside a zone"; then (( ZRC == 0 )) && pass "A1 a program executes inside a zone" \ @@ -304,8 +272,7 @@ else info "A2 note: /bin/sh appears static here; A2 proves less than usual" fi -# $HOME, not /: the zone root is a sealed read-only tmpfs, and the data -# directory (/ on the host) is bound at /home/. +# $HOME, not /: the zone root is sealed read-only, and the data directory is /home/. zrun alpha -- /bin/sh -c "$PRO echo hello > \$HOME/zonefile; sed 's/^/PROBE=/' \$HOME/zonefile" probe "A3 a zone can write and read back a file it owns" "hello" @@ -326,12 +293,10 @@ fi head_ "B. Two real zones cannot reach each other [unpriv]" # ============================================================================ -# keeper writes a canary into its own home. Not the ephemeral alpha: its writes -# never reach the host, and B1b needs a file to find. +# keeper, not the ephemeral alpha, whose writes never reach the host for B1b to find. zrun keeper -- /bin/sh -c "$PRO printf '%s' '$CANARY' > \$HOME/alpha-secret; echo PROBE=written" probe "B1a a persistent zone can write a file in its own zone" "written" -# Positive control: the file exists on the host. if [[ -f "$ROOTFS/keeper/alpha-secret" ]] && grep -q "$CANARY" "$ROOTFS/keeper/alpha-secret"; then pass "B1b positive control: the file is real and readable from the host" else @@ -343,15 +308,13 @@ fi zrun keeper -- /bin/sh -c "$PRO if grep -q '$CANARY' \$HOME/alpha-secret 2>/dev/null; then echo PROBE=kept; else echo PROBE=LOST; fi" probe "B1e a persistent zone still has its file on the NEXT launch" "kept" -# Control: the same sequence in an ephemeral zone must lose the file. zrun wiped -- /bin/sh -c "$PRO printf '%s' '$CANARY' > \$HOME/eph-secret; echo PROBE=written" probe "B1f control: an ephemeral zone can write the same file" "written" zrun wiped -- /bin/sh -c "$PRO if grep -q '$CANARY' \$HOME/eph-secret 2>/dev/null; then echo PROBE=KEPT; else echo PROBE=gone; fi" probe "B1g control: the ephemeral zone does NOT have it on its next launch" "gone" -# beta tries keeper's in-zone path and its host path; each zone binds only its -# own directory. +# Each zone binds only its own data directory. zrun beta -- /bin/sh -c "$PRO if cat /home/keeper/alpha-secret 2>/dev/null | grep -q '$CANARY'; then echo PROBE=LEAKED; else echo PROBE=denied; fi" probe "B1c another zone cannot read it at its in-zone path" "denied" @@ -359,15 +322,11 @@ zrun beta -- /bin/sh -c "$PRO if cat '$ROOTFS/keeper/alpha-secret' 2>/dev/null | probe "B1d another zone cannot read it by its host path" "denied" # --- process visibility ------------------------------------------------------ -# A marker process runs on the host, the parent of every zone, and no zone may -# see it. (One zone looking for another proves nothing: each run's pid -# namespace is gone when it exits.) The probe builds the token from two halves -# and scans /proc with a shell loop, so neither its argv nor a child's matches. +# A host process, not another zone: each zone's pid namespace is gone when it exits. MARKER_TOKEN="KRYPTIKMARKER7f3c091" MARKER_BIN="$WORK/${MARKER_TOKEN}_marker.sh" -# A script, not a renamed sleep: busybox dispatches on argv[0]. It must not -# exec, or the token would leave its command line. +# A script, as busybox dispatches on argv[0]; it must not exec, or the token leaves its argv. printf '#!/bin/sh\nsleep %s\n' "$TIMEOUT" > "$MARKER_BIN" chmod 0755 "$MARKER_BIN" /bin/sh "$MARKER_BIN" >/dev/null 2>&1 & @@ -377,7 +336,7 @@ BG_PIDS+=("$MARKER_PID") disown "$MARKER_PID" 2>/dev/null || true sleep 1 -# Positive control: the probe's own /proc scan finds the marker from the host. +# Positive control: the same /proc scan finds the marker from the host. host_hits=0 for f in /proc/[0-9]*/cmdline; do c="$(tr '\0' ' ' < "$f" 2>/dev/null)" @@ -417,7 +376,6 @@ probe "B3a alpha can write its private /tmp" "written" zrun beta -- /bin/sh -c "$PRO if grep -q '$CANARY' /tmp/canary 2>/dev/null; then echo PROBE=LEAKED; else echo PROBE=denied; fi" probe "B3b beta's /tmp does not contain alpha's file" "denied" -# ...and the zone's /tmp is not the host's /tmp either. HOSTTMP_CANARY="/tmp/kryptik_hosttmp_$$_$RANDOM" printf '%s' "$CANARY" > "$HOSTTMP_CANARY" zrun alpha -- /bin/sh -c "$PRO if grep -q '$CANARY' '$HOSTTMP_CANARY' 2>/dev/null; then echo PROBE=LEAKED; else echo PROBE=denied; fi" @@ -427,9 +385,8 @@ rm -f "$HOSTTMP_CANARY" # ============================================================================ head_ "C. Outside paths and inherited descriptors [unpriv]" # ============================================================================ -# The two escapes rootfs.rs exists to close. -# Positive control for the whole group. +# The two escapes rootfs.rs exists to close; C0 is the control for both. if grep -q "$CANARY" "$HOSTFIX/hostconfig.conf"; then pass "C0 positive control: the host fixture is readable outside the zone" else @@ -439,9 +396,7 @@ fi zrun alpha -- /bin/sh -c "$PRO if grep -q '$CANARY' '$HOSTFIX/hostconfig.conf' 2>/dev/null; then echo PROBE=LEAKED; else echo PROBE=denied; fi" probe "C1 an outside path is not reachable from inside a zone" "denied" -# A descriptor opened before the zone existed survives pivot_root and is not -# governed by Landlock, so rootfs.rs::close_inherited_fds must close it. The -# control shows fd 9 is inherited when nothing closes it. +# An inherited descriptor survives pivot_root and Landlock, so close_inherited_fds must close it. ctl="$(exec 9<"$HOSTFIX/hostconfig.conf"; /bin/sh -c 'cat <&9' 2>/dev/null)" if [[ "$ctl" == *"$CANARY"* ]]; then pass "C2a positive control: fd 9 is inherited and readable by a plain child" @@ -462,8 +417,7 @@ ZOUT="$(exec 200<"$HOSTFIX/hostconfig.conf"; KRYPTIK_EXPERIMENTAL=1 timeout "$TI ZRC=$? probe "C3 a high inherited descriptor (fd 200) cannot be read either" "denied" -# A directory descriptor is worse than a file one: it survives as a walk root -# and openat(dirfd, "..") climbs out of any subtree. +# A directory descriptor is worse: openat(dirfd, "..") climbs out of any subtree. ZOUT="$(exec 9<"$HOSTFIX"; KRYPTIK_EXPERIMENTAL=1 timeout "$TIMEOUT" \ "$KRYPTIKD" run alpha "${ZARGS[@]}" -- \ /bin/sh -c "$PRO if ls /proc/self/fd/9/ 2>/dev/null | grep -q hostconfig; then echo PROBE=LEAKED; else echo PROBE=denied; fi" 2>&1)" @@ -475,14 +429,13 @@ ZOUT="$(exec 5000<"$HOSTFIX/hostconfig.conf"; KRYPTIK_EXPERIMENTAL=1 timeout "$T "$KRYPTIKD" run alpha "${ZARGS[@]}" -- \ /bin/sh -c "$PRO if cat <&5000 2>/dev/null | grep -q '$CANARY'; then echo PROBE=LEAKED; else echo PROBE=denied; fi" 2>&1)" ZRC=$? -probe "C5 a descriptor above the old sweep bound (fd 5000) is closed too" "denied" +probe "C5 a descriptor above a fixed sweep bound (fd 5000) is closed too" "denied" # ============================================================================ head_ "D. Environment and host configuration exposure [unpriv]" # ============================================================================ -# The zone gets a fixed, minimal environment (spawn.rs::zone_environment): -# none of the caller's paths, tokens or LD_* variables. +# The zone's environment is fixed (spawn.rs::zone_environment), never the caller's. ZOUT="$(KRYPTIK_SYNTHETIC_SECRET="$CANARY" KRYPTIK_EXPERIMENTAL=1 timeout "$TIMEOUT" \ "$KRYPTIKD" run alpha "${ZARGS[@]}" -- \ /bin/sh -c "$PRO if /usr/bin/env | grep -q '$CANARY'; then echo PROBE=LEAKED; else echo PROBE=clean; fi" 2>&1)" @@ -509,8 +462,7 @@ probe "D4 the zone is told its own name via KRYPTIK_ZONE" "alpha" zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$PATH" probe "D5 PATH is the fixed zone PATH, not the caller's" "/usr/bin:/usr/sbin:/bin:/sbin" -# Machine-wide state that links zones or times keystrokes (rootfs.rs: -# PROC_MASKED, PROC_EMPTIED, SYSFS_KEPT, the zone's own boot_id). +# Machine-wide state that links zones or times keystrokes: rootfs.rs hides or replaces it. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(cat /proc/interrupts /proc/softirqs /proc/stat /proc/schedstat 2>/dev/null | wc -c)" probe "D6 the interrupt and context-switch counts are hidden from the zone" "0" @@ -542,7 +494,6 @@ probe "E1a the old root mountpoint is gone from the zone" "detached" zrun alpha -- /bin/sh -c "$PRO if grep -q 'oldroot' /proc/mounts 2>/dev/null; then echo PROBE=PRESENT; else echo PROBE=detached; fi" probe "E1b no oldroot mount remains in the zone's mount table" "detached" -# The host's own marker directories must not resolve. zrun alpha -- /bin/sh -c "$PRO if [ -d '$HOSTFIX' ]; then echo PROBE=PRESENT; else echo PROBE=detached; fi" probe "E1c the host's directory tree does not resolve inside the zone" "detached" @@ -561,8 +512,7 @@ probe "E2c positive control: /tmp IS writable (the zone is not simply inert)" "w zrun alpha -- /bin/sh -c "$PRO if grep -E ' /usr .*nosuid' /proc/mounts >/dev/null; then echo PROBE=nosuid; else echo PROBE=SUID_ALLOWED; fi" probe "E2d /usr is mounted nosuid" "nosuid" -# rootfs.rs::DEVICES, a private /dev/shm tmpfs, a private devpts (pts, ptmx) and -# the fd/stdin/stdout/stderr symlinks; nothing else. +# rootfs.rs::DEVICES, a private shm and devpts, and the fd/std* symlinks; nothing else. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(ls /dev | sort | tr '\n' ',')" probe "E3a /dev contains exactly the allowlisted entries" \ "fd,full,null,ptmx,pts,random,shm,stderr,stdin,stdout,tty,urandom,zero," @@ -570,10 +520,8 @@ probe "E3a /dev contains exactly the allowlisted entries" \ zrun alpha -- /bin/sh -c "$PRO for d in /dev/mem /dev/kmem /dev/port /dev/kvm /dev/sda /dev/sdd /dev/nvme0n1 /dev/input; do [ -e \$d ] && { echo PROBE=EXPOSED_\$d; exit 0; }; done; echo PROBE=absent" probe "E3b no hardware, memory or input devices are visible" "absent" -# /proc is the zone's own, not the host's. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(readlink /proc/self | sed 's/[0-9]*/n/')" if want_launch "E4a /proc is the zone's own pid namespace"; then - n="$(printf '%s\n' "$ZOUT" | sed -n 's/^PROBE=//p' | head -1)" # In its own pid namespace the shell sees a very small pid. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$\$" selfpid="$(printf '%s\n' "$ZOUT" | sed -n 's/^PROBE=//p' | head -1)" @@ -600,9 +548,8 @@ fi # ============================================================================ head_ "F. Encrypted storage: refused without its passphrase, real with it [unpriv; F4 root]" # ============================================================================ -# An encrypted zone starts only with its passphrase; without it the command -# never runs, override or not. F1 checks the converse: an implemented storage -# mode is not refused. + +# The converse of F2 and F3: an implemented storage mode runs without the override. zrun_raw wiped -- /bin/sh -c "echo $LAUNCHED; echo PROBE=RAN" if [[ "$ZOUT" == *"$LAUNCHED"* ]] && (( ZRC == 0 )); then pass "F1 ephemeral storage is implemented and runs without the override" @@ -637,8 +584,7 @@ else info "output: $(printf '%s' "$ZOUT" | tr '\n' '|' | cut -c1-220)" fi -# With a volume and its passphrase the zone starts, its home on the dm-crypt -# mapping. Root only (cryptsetup, dm-crypt, a loop device). +# Root only: a LUKS2 volume needs cryptsetup, dm-crypt and a loop device. if (( PRIVILEGED == 1 )) && command -v cryptsetup >/dev/null 2>&1 && [[ -e /dev/mapper/control ]]; then F4PASS="$WORK/sealed.pass"; printf 'fixture-passphrase' > "$F4PASS"; chmod 600 "$F4PASS" if ! F4INIT="$("$KRYPTIKD" volume init sealed --zones "$ZONES" --size 32M --passphrase-file "$F4PASS" "${IDENTITY[@]}" 2>&1)"; then @@ -662,8 +608,7 @@ if (( PRIVILEGED == 1 )) && command -v cryptsetup >/dev/null 2>&1 && [[ -e /dev/ else pass "F4b the mapping is closed when the zone exits" fi - # stop --now kills the zone's pid 1, so the launcher outlives it and - # closes the volume; killing the launcher left it open. + # stop --now kills pid 1, so the launcher outlives the zone and closes the volume. "$KRYPTIKD" run sealed "${ZARGS[@]}" --passphrase-file "$F4PASS" -- /bin/sleep 60 >/dev/null 2>&1 & BG_PIDS+=("$!") f4c_open=0 @@ -672,8 +617,7 @@ if (( PRIVILEGED == 1 )) && command -v cryptsetup >/dev/null 2>&1 && [[ -e /dev/ sleep 0.2 done "$KRYPTIKD" stop sealed --now >/dev/null 2>&1 - # The launcher closes the volume once the zone's pid 1 is gone, not - # before stop returns: up to 30 s on a slow host. + # The volume closes after pid 1 is gone, not when stop returns: up to 30 s on a slow host. f4c_closed=0 for _ in $(seq 150); do [[ "$("$KRYPTIKD" volume status sealed --zones "$ZONES" 2>/dev/null)" == *"(closed)"* ]] && { f4c_closed=1; break; } @@ -684,11 +628,10 @@ if (( PRIVILEGED == 1 )) && command -v cryptsetup >/dev/null 2>&1 && [[ -e /dev/ elif (( f4c_closed == 1 )); then pass "F4c stop --now leaves the volume closed" else - fail "F4c the volume is still open 10 s after stop --now" + fail "F4c the volume is still open 30 s after stop --now" "$KRYPTIKD" gc >/dev/null 2>&1 fi - # io_max: the leaf names the volume's devices with the bytes per - # second, and a direct write of 24 MiB at 8 MiB per second takes 3 s. + # io.max names the volume's devices; 24 MiB of direct writes at 8 MiB/s take 3 s. F5PASS="$WORK/throttled.pass"; printf 'fixture-passphrase' > "$F5PASS"; chmod 600 "$F5PASS" if ! F5INIT="$("$KRYPTIKD" volume init throttled --zones "$ZONES" --size 64M --passphrase-file "$F5PASS" "${IDENTITY[@]}" 2>&1)"; then fail "F5 volume init for the throttled fixture failed" @@ -721,8 +664,7 @@ if (( PRIVILEGED == 1 )) && command -v cryptsetup >/dev/null 2>&1 && [[ -e /dev/ fail "F5b io_max=8M did NOT hold: 24 MiB of direct writes took ${f5_ms} ms" fi fi - # destroy: refused while the volume is open, deletes the container - # once the zone is gone, and then there is nothing left to destroy. + # volume destroy, while the zone runs and after it stops. "$KRYPTIKD" run sealed "${ZARGS[@]}" --passphrase-file "$F4PASS" -- /bin/sleep 60 >/dev/null 2>&1 & BG_PIDS+=("$!") for _ in $(seq 50); do [[ -e /dev/mapper/kryptik-zone-sealed ]] && break; sleep 0.2; done @@ -765,10 +707,7 @@ else fail "G1 expected exit 127 for a missing command, got $ZRC" fi -# A zone killed by a signal reports 128+signo, as a shell does. Not with -# `kill -TERM $$`: the command is pid 1 of its pid namespace, and the kernel -# drops default-action signals sent to it from inside. A seccomp SIGSYS is -# synchronous and not subject to that. +# SIGSYS, not `kill -TERM $$`: the kernel drops default-action signals a pid 1 sends itself. zrun alpha -- /bin/sh -c "$PRO /bin/mount -t tmpfs none /tmp 2>/dev/null" if want_launch "G2 a zone killed by a signal reports 128+signo"; then if (( ZRC == 128 + 31 )); then @@ -778,8 +717,7 @@ if want_launch "G2 a zone killed by a signal reports 128+signo"; then fi fi -# A rootfs base that cannot be created must fail promptly, not hang on the -# sync pipe. +# A rootfs base that cannot be created must fail promptly, not hang on the sync pipe. start=$(date +%s) out="$(KRYPTIK_EXPERIMENTAL=1 timeout "$TIMEOUT" "$KRYPTIKD" run alpha \ --zones "$ZONES" --rootfs /proc/cannot-create-here \ @@ -796,7 +734,6 @@ else fail "G3 an unwritable rootfs base exited 0" fi -# A zone that is asked to run a directory rather than a program. zrun alpha -- /tmp if (( ZRC != 0 )) && (( ZRC != 124 )); then pass "G4 exec'ing a non-program fails cleanly (exit $ZRC)" @@ -804,9 +741,7 @@ else fail "G4 exec'ing a directory returned $ZRC" fi -# No zone process outlives the launcher. Scoped to this launch by a marker in -# its argv, as other runs may be live. Counted with wc -l: pgrep -c prints 0 -# and also fails when nothing matches. +# A marker scopes pgrep to this launch; wc -l, as pgrep -c also fails when nothing matches. G5MARK="G5PROBE_${$}_${RANDOM}" KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run alpha "${ZARGS[@]}" -- \ /bin/sh -c "$PRO echo PROBE=done; /bin/sleep 3; : $G5MARK" \ @@ -831,8 +766,7 @@ else info "output: $(tr '\n' '|' < "$WORK/g5.out" 2>/dev/null | cut -c1-200)" fi -# The host's mount table is unchanged: pivot_into makes the tree MS_PRIVATE -# first, so zone mounts cannot propagate out. +# pivot_into makes the tree MS_PRIVATE first, so zone mounts cannot propagate out. hm_before="$(wc -l < /proc/mounts)" zrun alpha -- /bin/sh -c "$PRO echo PROBE=done" hm_after="$(wc -l < /proc/mounts)" @@ -846,18 +780,15 @@ fi # ============================================================================ head_ "I. Seccomp is enforced on the real launch path [unpriv]" # ============================================================================ -# adversarial.sh tests the filter in a purpose-built child (`kryptikd -# seccomp-test`); these check that `run` installs it, after pivot_root and -# Landlock. +# adversarial.sh tests the filter alone; these check that `run` installs it. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(grep '^Seccomp:' /proc/self/status | awk '{print \$2}')" probe "I1 a zone process reports seccomp mode 2 (filtered)" "2" zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(grep '^Seccomp_filters:' /proc/self/status | awk '{print \$2}')" probe "I2 exactly one filter is installed, not zero and not a stack" "1" -# Each is in seccomp.rs::DENIED_RATIONALE. A denied syscall kills the zone -# (SIGSYS, exit 159) rather than returning an error it could ignore. +# A denied syscall (seccomp.rs::DENIED_RATIONALE) kills the zone, so it cannot ignore an error. seccomp_kill() { # desc shell-command local desc="$1" cmd="$2" zrun alpha -- /bin/sh -c "$cmd" @@ -876,8 +807,7 @@ seccomp_kill "I4 chroot(2) kills the zone (double-chroot escape)" \ '/usr/sbin/chroot / /bin/true' seccomp_kill "I5 unshare(2) kills the zone (nested namespace LPE surface)" \ '/usr/bin/unshare -U /bin/true' -# mknod(2) is allowed so mkfifo works; a device node is refused by Landlock -# (MAKE_CHAR/MAKE_BLOCK granted nowhere) and by nodev on every mount. +# mknod(2) is allowed for mkfifo; Landlock and nodev refuse a device node. zrun alpha -- /bin/sh -c "$PRO /usr/bin/mknod /tmp/n c 1 3 2>/dev/null; if [ -e /tmp/n ]; then echo PROBE=CREATED; else echo PROBE=refused; fi" probe "I6 a zone cannot create a device node (Landlock + nodev, not SIGSYS)" "refused" @@ -893,8 +823,7 @@ probe "I7 positive control: allowed syscalls still work under the filter" "allo head_ "J. The hardened zone tree [unpriv]" # ============================================================================ -# The zone root is a sealed read-only tmpfs, so a zone cannot plant a symlink -# on a future mount point for its next run. +# Sealed read-only, so a zone cannot plant a symlink on a mount point for its next run. zrun alpha -- /bin/sh -c "$PRO if touch /probe 2>/dev/null; then echo PROBE=WRITABLE; else echo PROBE=sealed; fi" probe "J1 the zone root is read-only, even to zone root" "sealed" @@ -908,8 +837,7 @@ probe "J2b positive control: \$HOME IS writable (the zone is not inert)" "writab zrun alpha -- /bin/sh -c "$PRO if mkdir /dev/evil 2>/dev/null; then echo PROBE=ALLOWED; else echo PROBE=denied; fi" probe "J3 Landlock denies creating a directory under /dev" "denied" -# Write rights Landlock must still grant: truncate needs FS_TRUNCATE, and mv -# across directories FS_REFER (without it mv falls back to copy+fchmod and fails). +# Write rights Landlock must grant; without FS_REFER, mv falls back to copy+fchmod and fails. zrun alpha -- /bin/sh -c "$PRO echo aaaa > /tmp/t; echo b > /tmp/t; echo PROBE=\$(cat /tmp/t)" probe "J4 truncating an existing file works (FS_TRUNCATE granted)" "b" @@ -929,8 +857,7 @@ probe "J8 no host identity or secret files are visible in /etc" "absent" zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(hostname)" probe "J9 the zone's hostname is the zone name, not the host's" "alpha" -# Positive control: without the CA bundle TLS breaks in every zone. Checked -# only where the host has one. +# Positive control: without the CA bundle TLS breaks in every zone. if [[ -d /etc/ssl/certs ]]; then zrun alpha -- /bin/sh -c "$PRO if [ -d /etc/ssl/certs ]; then echo PROBE=present; else echo PROBE=MISSING; fi" probe "J10 positive control: the CA certificate directory reaches the zone" "present" @@ -938,20 +865,15 @@ else skip "J10 the host has no /etc/ssl/certs, so the CA passthrough cannot be checked here" fi -# Remounting a recursive bind MS_RDONLY only affects the top mount, not its -# submounts. +# Remounting a recursive bind MS_RDONLY only affects the top mount, not its submounts. zrun alpha -- /bin/sh -c "$PRO n=\$(awk '\$5 ~ /^\/(usr|lib|lib64|bin|sbin|etc)/ && \$6 ~ /(^|,)rw(,|\$)/ {c++} END{print c+0}' /proc/self/mountinfo); echo PROBE=\$n" probe "J11 no system mount or submount is read-write inside the zone" "0" # ============================================================================ head_ "H. Network isolation, without overclaiming [unpriv]" # ============================================================================ -# network.mode = "none". Routed zones are group NETR. -# Devices the kernel creates in every new netns, which the zone cannot remove -# (sit0: CONFIG_IPV6_SIT=y adds a fallback tunnel to each). H1 allows these -# names and no others, and every device but lo must be down with no address. -# The same list as adversarial.sh. +# Devices every netns gets and a zone cannot remove (sit0: CONFIG_IPV6_SIT=y); as in adversarial.sh. KERNEL_FALLBACK_IFS="sit0" FALLBACK_RE="lo|${KERNEL_FALLBACK_IFS// /|}" @@ -991,24 +913,22 @@ probe "H2 a mode=none zone has no routes at all" "none" zrun alpha -- /bin/sh -c "$PRO if timeout 3 /bin/sh -c 'exec 3<>/dev/tcp/10.255.255.1/80' 2>/dev/null; then echo PROBE=CONNECTED; else echo PROBE=unreachable; fi" probe "H3 an outbound TCP connect from a mode=none zone cannot succeed" "unreachable" -# Positive control for H1, which only discriminates if the host has more than -# lo. The developer VM has no NIC (-nic none), so there it is NOT RUN. +# Positive control for H1, which only discriminates on a host with more than lo. hostifs="$(tail -n +3 /proc/net/dev | awk '{print $1}' | tr -d ':' | grep -cv '^lo$')" if (( hostifs > 0 )); then pass "H1c positive control: the host has $hostifs non-loopback interface(s) the zone did not see" else skip "H1c H1 cannot discriminate here: this host has no non-loopback interface either" - info " (expected in the developer VM, which is launched with -nic none)" + info " (expected on an installed system, where the net zone holds the interfaces)" fi # ============================================================================ head_ "K. The privileged launch path [vm / root only]" # ============================================================================ -# What changes when kryptikd is root, as it runs in zone 0 on Kryptik. +# The path the installed system takes: kryptikd runs as root in zone 0. if (( PRIVILEGED == 1 )); then - # Mapping zone root to host uid 0 would make it real root for every DAC - # check on every bound path. + # Zone root as host uid 0 would be real root for every DAC check on every bound path. out="$(KRYPTIK_EXPERIMENTAL=1 timeout "$TIMEOUT" "$KRYPTIKD" run alpha \ --zones "$ZONES" --rootfs "$ROOTFS" -- /bin/echo "$LAUNCHED" 2>&1)" rc=$? @@ -1021,12 +941,10 @@ if (( PRIVILEGED == 1 )); then info "output: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-200)" fi - # Zone root is uid 0 inside the zone... zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(id -u)" probe "K2 the zone's process is uid 0 inside its own user namespace" "0" - # ...and its files belong to the mapped host identity, checked on the host - # side. `keeper`, since an ephemeral zone writes nothing there. + # keeper, since an ephemeral zone writes nothing to the host. zrun keeper -- /bin/sh -c "$PRO echo k3 > \$HOME/k3file; echo PROBE=written" if want_launch "K3 a zone's files are owned by the mapped identity"; then owner="$(stat -c %u "$ROOTFS/keeper/k3file" 2>/dev/null)" @@ -1038,15 +956,12 @@ if (( PRIVILEGED == 1 )); then fi fi - # Supplementary groups are dropped on a privileged launch (setgroups needs - # CAP_SETGID). That only shows if the launcher has groups to drop, and a - # root started by init often has none, so it is given some. + # A root started by init often has no groups to drop, so K4 gives the launcher some. K4_GROUPS="4,27" K4_WRAP=() launcher_groups="$(grep '^Groups:' /proc/self/status | cut -f2- | wc -w)" if (( launcher_groups == 0 )); then - # busybox setpriv has no --groups; s6-applyuidgid -G does, and the image - # ships s6. A wrapper that grants no groups is discarded. + # busybox setpriv has no --groups, but the image ships s6-applyuidgid -G. for k4_cand in "setpriv --groups=$K4_GROUPS --" "s6-applyuidgid -G $K4_GROUPS"; do read -ra k4_try <<< "$k4_cand" command -v "${k4_try[0]}" >/dev/null 2>&1 || continue @@ -1083,9 +998,7 @@ if (( PRIVILEGED == 1 )); then zrun alpha -- /bin/sh -c "$PRO if touch /usr/rootprobe 2>/dev/null; then echo PROBE=WRITABLE; else echo PROBE=readonly; fi" probe "K6 /usr is read-only even to a privileged launch" "readonly" - # A data directory owned by anyone but the zone's identity is refused, or - # one planted by another user would become the zone's. The suite's own - # chown never exercises that. + # Refused, or a data directory another user planted would become the zone's. mkdir -p "$ROOTFS/stranger" chown 100001:100001 "$ROOTFS/stranger" 2>/dev/null out="$(KRYPTIK_EXPERIMENTAL=1 timeout "$TIMEOUT" "$KRYPTIKD" run stranger \ @@ -1100,14 +1013,12 @@ if (( PRIVILEGED == 1 )); then info "output: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-200)" fi else - skip "K1-K6 the privileged launch path [vm] needs root; run this suite inside the developer VM" + skip "K1-K7 the privileged launch path [vm] needs root; tools/image/zones-test.sh runs this suite as root" fi # --- T11: the zone as the host sees it -------------------------------------- -# Every other identity check asks the zone. This reads the zone's pid 1 (from -# the registry) in /proc from outside: a zone mapped to real root, with a -# capability left effective or a namespace shared with pid 1 fails here even -# if every in-zone check passes. + +# Every other identity check asks the zone; this reads its pid 1 in the host's /proc. if (( PRIVILEGED == 1 )); then KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run alpha "${ZARGS[@]}" -- \ /bin/sh -c "$PRO echo PROBE=up; /bin/sleep 30" > "$WORK/t11.out" 2>&1 & @@ -1176,9 +1087,8 @@ head_ "L. Supervision, termination and the filter probes [unpriv]" # ============================================================================ # --- nothing outlives the launcher ------------------------------------------- -# A unique sleep duration is the marker: it appears in the zone process's argv -# and in nothing else. It comes from this run's pid, since pgrep and pkill see -# every process of the user, another run of this suite included. + +# A sleep duration from this run's pid marks the zone: pgrep also sees other runs' processes. MARK_BASE=$(( 3000000 + ($$ % 100000) * 10 )) MARK_KILL=$(( MARK_BASE + 1 )) KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run alpha "${ZARGS[@]}" -- /bin/sleep "$MARK_KILL" >/dev/null 2>&1 & @@ -1202,13 +1112,12 @@ else fi # --- SIGTERM to the launcher ------------------------------------------------- -# The check is that no zone process survives, not timeout(1)'s exit code: 124 -# from coreutils, the child's status (137) from busybox. The zone's pid 1 -# ignores SIGTERM, so the launcher's SIGKILL 5 s later is what ends it. + +# Judged by survivors, not timeout(1)'s status: 124 from coreutils, 137 from busybox. MARK_TERM=$(( MARK_BASE + 2 )) KRYPTIK_EXPERIMENTAL=1 timeout 2 "$KRYPTIKD" run alpha "${ZARGS[@]}" -- /bin/sleep "$MARK_TERM" >/dev/null 2>&1 trc=$? -# The escalation is 5s after the signal, so wait past it before judging. +# pid 1 ignores SIGTERM, and the launcher's SIGKILL follows 5 s later: wait past it. sleep 6 after="$(pgrep -f "sleep $MARK_TERM" 2>/dev/null | wc -l)" if (( trc == 0 )); then @@ -1222,9 +1131,8 @@ else fi # --- the filter probes ------------------------------------------------------- -# `kryptikd seccomp-test` makes the syscall in a child under the real zone -# filter. Exit 5: killed by SIGSYS; 7: refused with the intended errno; 0: the -# call completed (the positive control). + +# seccomp-test exits 5 when SIGSYS kills the call, 7 on the intended errno, 0 when it completes. filter_probe() { # desc probe expected local desc="$1" probe_name="$2" want="$3" timeout "$TIMEOUT" "$KRYPTIKD" seccomp-test "$probe_name" >/dev/null 2>&1 @@ -1243,9 +1151,7 @@ filter_probe "L6 socket(AF_NETLINK/NETFILTER) is refused with an errno" socket- filter_probe "L7 ioctl(TIOCSTI) is killed (terminal input injection)" ioctl-tiocsti 5 filter_probe "L8 positive control: socket(AF_INET) still works" socket-inet 0 -# seccomp-trace names each refused call and lets the program carry on: -# reboot(2) is refused by the zone filter and fails with ENOSYS (38), and so -# does inotify_init1(2), marked soft since a zone gets ENOSYS for it too. +# reboot(2) and inotify_init1(2), a soft refusal, both get ENOSYS (38) and the program goes on. out="$(timeout "$TIMEOUT" "$KRYPTIKD" seccomp-trace -- python3 -c \ 'import ctypes; c = ctypes.CDLL(None, use_errno=True); [print(c.syscall(nr, 0, 0, 0, 0), ctypes.get_errno()) for nr in (169, 294)]' 2>&1)" if grep -qx 'KRYPTIK_SECCOMP_DENIED 169 reboot' <<<"$out" \ @@ -1256,9 +1162,7 @@ else fail "L9 seccomp-trace did not report reboot(2) and inotify_init1(2) [$(tr '\n' ' ' <<<"$out")]" fi -# ncurses brackets each terminfo open with setfsuid and setfsgid. The filter -# answers them with EPERM; a kill would take every terminal program with it -# (tput would end with 159, SIGSYS). +# ncurses calls setfsuid and setfsgid around terminfo opens: EPERM, as a kill would end tput. if command -v tput > /dev/null 2>&1; then zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(tput -T xterm cols 2>/dev/null || echo exit-\$?)" probe "L10 a terminal program opens terminfo in a zone and lives" "80" @@ -1266,16 +1170,15 @@ else info "L10 not run: this host has no tput" fi -# cp -a and gzip give what they make its source's owner (tar does too, as -# root); with chown refused neither would finish. +# cp -a and gzip chown what they make to its source's owner; refusing chown would stop both. zrun alpha -- /bin/sh -c "$PRO cd /tmp && echo x > o && cp -a o o2 && gzip -k o && echo PROBE=kept" probe "L11 cp -a and gzip keep an owner in a zone and live" "kept" -# install(1) resets a file's ACL through its xattrs, and Python's asyncio -# watches a child through a pidfd. +# install(1) resets a file's ACL through its xattrs. zrun alpha -- /bin/sh -c "$PRO echo x > /tmp/src && install -D -m 644 /tmp/src /tmp/i/x && echo PROBE=installed" probe "L12 install(1) sets a mode in a zone and lives" "installed" if command -v python3 > /dev/null 2>&1; then + # asyncio watches its child through a pidfd. zrun alpha -- /bin/sh -c "$PRO python3 -c 'import asyncio async def m(): p = await asyncio.create_subprocess_exec(\"true\") @@ -1288,8 +1191,7 @@ asyncio.run(m())'" f = open(\"/tmp/m\", \"w+b\"); f.write(bytes(4096)); f.flush() m = mmap.mmap(f.fileno(), 4096); m[0:1] = b\"y\"; m.flush(); print(\"PROBE=flushed\")'" probe "L14 timeout(1) and a flushed mapping live in a zone" "flushed" - # sudo, su and daemons dropping privilege call the set*id family as root: - # refused with EPERM, they can say so instead of dying of SIGSYS. + # sudo, su and daemons call set*id as root: EPERM lets them say so, SIGSYS would kill them. zrun alpha -- /bin/sh -c "$PRO python3 -c 'import os try: os.setgroups([]); os.setgid(65534); os.setuid(65534); print(\"PROBE=CHANGED\") @@ -1303,11 +1205,8 @@ fi # ============================================================================ head_ "M. cgroup resource limits [unpriv where delegated, otherwise vm]" # ============================================================================ -# Where a cgroup can be created the limits must be enforced, and where one -# cannot the zone must be refused: never started unlimited. -# Probe as kryptikd does, by trying: a delegated subtree is writable by a user, -# and root in a container may find the hierarchy read-only. +# Probe by trying, as kryptikd does: a user may have a delegated subtree, root in a container none. CGROUP_OK=0 if [[ -f /sys/fs/cgroup/cgroup.controllers ]] \ && mkdir /sys/fs/cgroup/kryptik-suite-probe 2>/dev/null; then @@ -1318,7 +1217,6 @@ fi if (( CGROUP_OK == 0 )); then info "this host cannot create cgroups; checking the REFUSAL instead of enforcement" - # The refusal must name the limits, and the command must not run. zrun_raw pidcapped -- /bin/sh -c "echo $LAUNCHED" if [[ "$ZOUT" == *"$LAUNCHED"* ]]; then fail "M1 a zone declaring [limits] RAN on a host that cannot enforce them" @@ -1336,9 +1234,8 @@ else info "cgroups are creatable here; checking enforcement" # --- cpu_max ------------------------------------------------------------ - # A busy loop for two seconds; the leaf's cpu.stat says how much CPU time - # it got. Under cpu_max = "25%" that is a quarter of the wall time, and - # roomy, unlimited, gets the whole of it. + + # A 2 s busy loop's CPU time from cpu.stat: a quarter of it under 25%, all of it in roomy. cpu_probe() { # zone -> sets CPU_LIMIT (cpu.max), CPU_USEC (used in about 2 s) local zone="$1" CPU_LIMIT=""; CPU_USEC="" @@ -1382,11 +1279,8 @@ else fi # --- pids_max ----------------------------------------------------------- - # Measured from outside: a zone out of pids cannot fork to report on itself. - # The cgroup leaf is ., so $! gives the path. Polled from - # 0.2 s, keeping the last good reading, as the cgroup goes when the zone - # dies. The verdict uses pids.events `max` (forks refused) and pids.peak; - # pids.current only describes the instant it was read. + + # Measured from outside, as a zone out of pids cannot fork to report on itself. pids_probe() { # zone ATTEMPTS -> sets PIDS_CUR, PIDS_PEAK, PIDS_MAXEV, PIDS_LIMIT local zone="$1" local ATTEMPTS="${2:-20}" @@ -1394,8 +1288,7 @@ else PIDS_SAMPLES=0; PIDS_TRACE="" PIDS_ERR="$WORK/pids-probe-$zone.err" PIDS_LEAF="" - # busybox ash, not bash: bash aborts when a fork fails and the zone is - # gone before the poll sees it; ash reports the failure and carries on. + # busybox ash carries on past a failed fork; bash aborts, ending the zone before a poll. local sh_cmd=(/bin/sh -c) [[ -x /bin/busybox ]] && sh_cmd=(/bin/busybox ash -c) KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run "$zone" "${ZARGS[@]}" -- \ @@ -1411,16 +1304,14 @@ else if [[ -d "$leaf" ]]; then cur="$(cat "$leaf/pids.current" 2>/dev/null)" lim="$(cat "$leaf/pids.max" 2>/dev/null)" - # pids.events also counts refusals by an ancestor's limit; - # pids.events.local only this cgroup's. + # pids.events.local counts only this cgroup's refusals, not an ancestor's. ev="$(sed -n 's/^max //p' "$leaf/pids.events.local" 2>/dev/null)" [[ -z "$ev" ]] && ev="$(sed -n 's/^max //p' "$leaf/pids.events" 2>/dev/null)" pk="$(cat "$leaf/pids.peak" 2>/dev/null)" [[ -n "$cur" ]] && PIDS_CUR="$cur" [[ -n "$lim" ]] && PIDS_LIMIT="$lim" [[ -n "$ev" ]] && PIDS_MAXEV="$ev" - # pids.peak needs kernel 6.1+; fall back to the largest - # pids.current we happened to see. + # pids.peak needs kernel 6.1+; otherwise the largest pids.current seen. if [[ -n "$pk" ]]; then PIDS_PEAK="$pk" elif [[ -n "$cur" ]] && { [[ -z "$PIDS_PEAK" ]] || (( cur > PIDS_PEAK )); }; then @@ -1429,7 +1320,7 @@ else PIDS_SAMPLES=$((PIDS_SAMPLES+1)) PIDS_TRACE="$PIDS_TRACE [$i cur=$cur pk=$pk ev=$ev]" elif [[ -n "$PIDS_CUR" ]]; then - break # it existed, we read it, and it has now been cleaned up + break # read while it existed, and now removed with the zone fi if (( i < 200 )); then step=0.005; else step=0.1; fi sleep "$step" @@ -1461,11 +1352,7 @@ else pass "M3a pids_max reaches the kernel: the zone's cgroup has pids.max=$PIDS_LIMIT" fi - # M3b: enforcement, measured by the refusal. At pids.max fork(2) fails with - # EAGAIN and the shell, the zone's pid 1, dies and takes the zone and its - # cgroup with it within milliseconds, so reading the cgroup races its - # removal. The EAGAIN on stderr does not race, and with the same payload - # fine in roomy it can only come from the pids controller. + # M3b reads the EAGAIN on stderr: the cgroup goes with the zone within milliseconds. fork_storm() { # zone attempts -> sets FS_RC, FS_OUT local zone="$1" attempts="$2" local sh_cmd=(/bin/sh -c) @@ -1500,19 +1387,15 @@ else fi # --- memory_max --------------------------------------------------------- - # Writing to /dev/shm charges the zone's memory cgroup. memory.oom.group=1 - # kills the whole zone rather than one process, so the launcher sees 137. + + # /dev/shm is charged to the zone's cgroup; memory.oom.group kills the whole zone (137). zrun roomy -- /bin/sh -c "$PRO dd if=/dev/zero of=/dev/shm/blob bs=1M count=32 2>/dev/null && echo PROBE=wrote32M" probe "M4 positive control: 32M fits inside a 512M zone" "wrote32M" - # M5 reads the kernel's oom_kill counter: exit 137 alone is what any SIGKILL - # gives. + # M5 reads the kernel's oom_kill counter: exit 137 alone is what any SIGKILL gives. MEM_OOM=""; MEM_GROUP=""; MEM_LEAF=""; MEM_RC="" - # The zone's leaf goes milliseconds after the OOM kill, so polling it - # usually misses. memory.events is hierarchical and the parent outlives - # every zone: its counter, read either side of this one launch, gives the - # delta. The leaf poll names the exact cgroup when it does win. + # The leaf goes right after the kill, so the parent's hierarchical counter gives the delta. MEM_PARENT="/sys/fs/cgroup/kryptik" mem_ev() { sed -n "s/^$1 //p" "$MEM_PARENT/memory.events" 2>/dev/null; } MEM_P_BEFORE="$(mem_ev oom_kill)" @@ -1524,8 +1407,7 @@ else mempid=$! BG_PIDS+=("$mempid") MEM_LEAF="$MEM_PARENT/memcapped.${mempid}" - # Poll for as long as the launcher lives: in the emulated VM the OOM lands - # around 36 s. + # Poll while the launcher lives: in the emulated VM the OOM lands around 36 s. i=0 while (( i < 1200 )); do # 120s hard ceiling if [[ -d "$MEM_LEAF" ]]; then @@ -1534,7 +1416,7 @@ else [[ -n "$v" ]] && (( v > 0 )) && MEM_OOM="$v" [[ -n "$g" ]] && (( g > 0 )) && MEM_GROUP="$g" fi - kill -0 "$mempid" 2>/dev/null || break # the launcher is done + kill -0 "$mempid" 2>/dev/null || break sleep 0.1 i=$((i+1)) done @@ -1542,7 +1424,7 @@ else MEM_RC=$? memout="$(cat "$WORK/mem.out" 2>/dev/null)" - # One count, and a statement of where it came from. + # The kill count, and where it was read. MEM_KILLS=""; MEM_GROUP_KILLS=0; MEM_SRC="" if [[ -n "$MEM_OOM" ]]; then MEM_KILLS="$MEM_OOM"; MEM_GROUP_KILLS="${MEM_GROUP:-0}" @@ -1570,12 +1452,10 @@ else fi elif [[ -z "$MEM_SRC" ]]; then skip "M5 memory_max: no readable oom_kill counter (leaf $MEM_LEAF, parent $MEM_PARENT)" - info " exit was $MEM_RC, which on its own is also what a SIGKILL from the test would give," - info " so this run neither proves nor disproves the limit" + info " exit $MEM_RC alone could be any SIGKILL, so this run neither proves nor disproves the limit" info " launcher said: $(printf '%s' "$memout" | tr '\n' '|' | cut -c1-260)" elif [[ -n "$MEM_KILLS" ]] && (( MEM_KILLS > 0 )); then - # A pass needs both signals. The kernel counted an OOM kill but the - # launcher did not exit 137: not a pass, and not a failed limit either. + # An OOM kill without exit 137 is neither a pass nor a failed limit. skip "M5 the kernel OOM-killed the zone but the launcher exited $MEM_RC, not 137" info " $MEM_SRC" else @@ -1589,9 +1469,8 @@ else probe "M6 a zone cannot write the cgroup filesystem from inside" "denied" # --- cleanup ------------------------------------------------------------ - # A launcher that exits normally removes its cgroup (rmdir fails with EBUSY - # while a process remains). One launch only: M2, M3 and M8 kill their - # launchers and leave empty cgroups for M9's sweep. + + # Only this launch's leaf: M2, M3 and M8 kill their launchers and leave cgroups for M9. KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run roomy "${ZARGS[@]}" -- /bin/true >/dev/null 2>&1 & normal_lp=$! wait "$normal_lp" 2>/dev/null @@ -1612,8 +1491,7 @@ else kill -9 "$cgpid" 2>/dev/null sleep 2 after_procs="$(pgrep -f "sleep $MARK_CG" 2>/dev/null | wc -l)" - # Leaves whose launcher (the pid in the name) is gone. A live launcher's - # leaf is a running zone, such as the installed system's net zone. + # Leaves whose launcher (the pid in the name) is gone; a live one may be the net zone's. abandoned_leaves() { local d n p for d in /sys/fs/cgroup/kryptik/*/; do @@ -1624,8 +1502,7 @@ else done } after_cg="$(abandoned_leaves | wc -l)" - # A SIGKILLed launcher cannot clean up, but PR_SET_PDEATHSIG kills every - # process: its cgroup must be empty, for the next launch to sweep (M9). + # PR_SET_PDEATHSIG kills every zone process, leaving an empty cgroup for M9 to sweep. if (( after_procs != 0 )); then fail "M8 $after_procs process(es) survived a SIGKILLed launcher with limits" pkill -9 -f "sleep $MARK_CG" 2>/dev/null @@ -1643,8 +1520,7 @@ else fi fi - # M9: the next launch that needs a cgroup removes empty leaves older than - # the staleness window. + # M9: the next launch with limits removes empty leaves past the staleness window. if (( after_cg > 0 )); then sleep 6 # older than cgroup.rs::STALE_AFTER zrun pidcapped -- /bin/sh -c "$PRO echo PROBE=swept" @@ -1655,8 +1531,7 @@ else pass "M9 the next launch swept the abandoned cgroup" else fail "M9 $still abandoned cgroup(s) survived the next launch's sweep" - # Which, and why: what the leaf holds, and what rmdir itself - # says about it. + # What each leaf holds, and what rmdir says about it. while IFS= read -r d; do [[ -n "$d" ]] || continue info " $(basename "$d"): procs=[$(tr '\n' ' ' < "$d/cgroup.procs" 2>/dev/null)]; $(tr '\n' ' ' < "$d/cgroup.events" 2>/dev/null); rmdir: $(rmdir "$d" 2>&1 && echo ok)" @@ -1672,10 +1547,6 @@ fi # ============================================================================ head_ "E-EPH. Ephemeral zones keep nothing [unpriv]" # ============================================================================ -# An ephemeral $HOME is a per-launch tmpfs in the zone's mount namespace. The -# kernel frees it when the namespace dies, so there is no teardown for a crash -# to skip. It is not secure erasure (tmpfs pages can be swapped out), and EPH8 -# checks that the tooling says so. # A dedicated rootfs base, so leftovers from other groups cannot confuse EPH4. EPHROOT="$WORK/ephroot" @@ -1693,11 +1564,9 @@ ephrun() { # zone -- cmd... return 0 } -# EPH1, the positive control: $HOME works. ephrun alpha -- /bin/sh -c "$PRO printf '%s' '$CANARY' > \$HOME/secret; cat \$HOME/secret | sed 's/^/PROBE=/'" probe "EPH1 positive control: an ephemeral zone can write and read its \$HOME" "$CANARY" -# EPH2: the zone's directory on the host stays empty. if [[ -d "$EPHROOT/alpha" ]]; then n="$(find "$EPHROOT/alpha" -mindepth 1 2>/dev/null | wc -l)" if (( n == 0 )); then @@ -1710,11 +1579,10 @@ else fail "EPH2 the persistent directory was not created at all" fi -# EPH3: a second launch cannot see the first launch's data. ephrun alpha -- /bin/sh -c "$PRO if [ -e \$HOME/secret ]; then echo PROBE=RECOVERED; else echo PROBE=gone; fi" probe "EPH3 a later launch cannot recover the previous run's data" "gone" -# EPH4: the same after the launcher is SIGKILLed. +# The kernel frees the tmpfs with the zone's mount namespace: no teardown for a crash to skip. MARK_EPH=$(( MARK_BASE + 4 )) KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run beta "${EPHARGS[@]}" -- \ /bin/sh -c "printf '%s' '$CANARY' > \$HOME/crashfile; sleep $MARK_EPH" >/dev/null 2>&1 & @@ -1736,7 +1604,6 @@ if want_launch "EPH4 a crashed launcher leaves nothing recoverable"; then fi fi -# EPH5: the zone's tmpfs never appears in the host's mount table. hm_before="$(wc -l < /proc/mounts)" ephrun alpha -- /bin/sh -c "$PRO echo PROBE=done" hm_after="$(wc -l < /proc/mounts)" @@ -1747,12 +1614,9 @@ else grep "$EPHROOT" /proc/mounts 2>/dev/null | sed 's/^/ /' | head -3 fi -# EPH6: storage.size bounds the tmpfs: 128M written into a 64M zone. ephrun alpha -- /bin/sh -c "$PRO dd if=/dev/zero of=\$HOME/big bs=1M count=128 2>/dev/null; s=\$(wc -c < \$HOME/big 2>/dev/null || echo 0); if [ \"\$s\" -gt 100000000 ]; then echo PROBE=UNBOUNDED; else echo PROBE=bounded; fi" probe "EPH6 storage.size bounds the tmpfs (128M into a 64M zone is truncated)" "bounded" -# EPH7: an ephemeral zone will not start over data it did not write, and -# kryptikd refuses rather than deleting it. mkdir -p "$EPHROOT/wiped" (( PRIVILEGED == 1 )) && chown "$ZONE_UID:$ZONE_GID" "$EPHROOT/wiped" 2>/dev/null echo "left over from an earlier build" > "$EPHROOT/wiped/stale.txt" @@ -1772,7 +1636,7 @@ else fi rm -rf "$EPHROOT/wiped" -# EPH8: the tooling must not let "ephemeral" be read as secure erasure. +# Swap can hold tmpfs pages, so the tooling must not let "ephemeral" read as secure erasure. expl="$("$KRYPTIKD" explain alpha "${EPHARGS[@]}" 2>&1)" if [[ "$expl" == *tmpfs* && "$expl" == *swap* && "$expl" == *"NOT secure erasure"* ]]; then pass "EPH8 explain says what ephemeral is, and that swap makes it not erasure" @@ -1784,12 +1648,8 @@ fi # ============================================================================ head_ "CAP. The capability bounding set [unpriv]" # ============================================================================ -# Zone root keeps only CAP_NET_BIND_SERVICE (bit 10, 0x400). NET_ADMIN and -# NET_RAW matter most: in a zone that owns a veth they would let it re-address -# its link and send raw frames on the bridge segment. -# Positive control: 0x400 in the zone shows a drop only if the launcher's -# bounding set is wider. +# Positive control: a zone's 0x400 shows a drop only if the launcher's bounding set is wider. host_capbnd="$(grep -m1 '^CapBnd:' /proc/self/status | awk '{print $2}')" if [[ -n "$host_capbnd" && "$host_capbnd" != "0000000000000400" ]]; then pass "CAP0 positive control: the launcher's bounding set is $host_capbnd, wider than a zone's" @@ -1804,23 +1664,19 @@ probe "CAP1 the zone's bounding set is CAP_NET_BIND_SERVICE and nothing else" "0 zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(grep -m1 '^CapEff:' /proc/self/status | awk '{print \$2}')" probe "CAP2 the zone's effective set is the same single capability" "0000000000000400" -# The ones that matter most, by number so a failure names them: 21 SYS_ADMIN, -# 12 NET_ADMIN, 13 NET_RAW, 16 SYS_MODULE, 19 SYS_PTRACE, 27 MKNOD. +# 21 SYS_ADMIN, 12 NET_ADMIN, 13 NET_RAW, 16 SYS_MODULE, 19 SYS_PTRACE, 27 MKNOD. zrun alpha -- /bin/sh -c "$PRO b=\$(grep -m1 '^CapBnd:' /proc/self/status | awk '{print \$2}'); v=\$(printf '%d' 0x\$b); bad=''; for c in 21 12 13 16 19 27; do if [ \$(( (v >> c) & 1 )) -eq 1 ]; then bad=\"\$bad \$c\"; fi; done; if [ -n \"\$bad\" ]; then echo PROBE=KEPT\$bad; else echo PROBE=dropped; fi" probe "CAP3 SYS_ADMIN, NET_ADMIN, NET_RAW, SYS_MODULE, PTRACE and MKNOD are gone" "dropped" -# Positive control: an empty bounding set would pass CAP1-CAP3 and break every -# zone. +# Positive control: an empty bounding set would pass CAP1-CAP3 and break every zone. zrun alpha -- /bin/sh -c "$PRO echo hi > \$HOME/capfile && cat \$HOME/capfile | sed 's/^/PROBE=/'" probe "CAP4 positive control: the zone still runs normally after the drop" "hi" # ============================================================================ head_ "BRK. The broker channel [unpriv + vm]" # ============================================================================ -# The launcher serves /run/kryptik/broker for its zone and identifies the peer -# by SO_PEERCRED, never by what the zone sends. The client is python: bash has -# no AF_UNIX and busybox nc no -U. A refused peer can be answered and closed -# before it sends, so EPIPE is ignored and the answer read anyway. + +# Python, as bash has no AF_UNIX and busybox nc no -U; a refused peer may get EPIPE first. BRK_CLIENT='import socket,sys s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(5) @@ -1832,12 +1688,10 @@ except BrokenPipeError: sys.stdout.write(s.recv(256).decode(errors="replace").strip())' if command -v python3 >/dev/null 2>&1; then - # The broker names the zone from the connecting uid; a zone cannot ask to - # be another. + # The broker names the zone from the peer's uid (SO_PEERCRED), not from what it sends. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(python3 -c '$BRK_CLIENT' /run/kryptik/broker version 2>&1)" probe "BRK1 a zone's broker answers version, naming that zone" "kryptik-broker 1 zone=alpha" - # The server parses rather than echoes. zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(python3 -c '$BRK_CLIENT' /run/kryptik/broker notaverb 2>&1)" probe "BRK2 an unknown verb is refused rather than echoed" "error: unknown verb" @@ -1848,8 +1702,7 @@ else skip "BRK1-BRK3 need python3 for an AF_UNIX client; this environment has none" fi -# BRK4, the authentication, needs two uids: only a privileged launch gives each -# zone its own. +# BRK4 needs two uids, and only a privileged launch gives each zone its own. if (( PRIVILEGED == 1 )) && command -v python3 >/dev/null 2>&1; then KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run alpha "${ZARGS[@]}" -- \ /bin/sh -c "$PRO echo PROBE=up; /bin/sleep 20" > "$WORK/brk.out" 2>&1 & @@ -1884,16 +1737,13 @@ fi # ============================================================================ head_ "NETR. Routed networking, end to end [vm / root only]" # ============================================================================ -# The nic zone holds the bridge (and any physical interfaces it names); a -# routed zone gets a veth into it. Starting a nic zone can take interfaces -# from zone 0, so this runs only in a disposable VM that says so -# (KRYPTIK_VM_DISPOSABLE=1), never as root on a real machine. + +# A nic zone can take zone 0's interfaces, so this runs only where KRYPTIK_VM_DISPOSABLE=1. if (( PRIVILEGED == 1 )) && [[ "${KRYPTIK_VM_DISPOSABLE:-}" == "1" ]]; then - # A routed fixture. `carrier` already holds the nic. + # A routed fixture; `carrier` already holds the nic. mkzone router none "#0f0f0f" sed -i 's/^mode = "none"$/mode = "routed"/' "$ZONES/router.toml" - # A routed zone's bridge address comes from its uid_base - # (netzone::host_number); without one it starts with loopback only. + # The bridge address comes from uid_base (netzone::host_number); without it, loopback only. printf '[identity]\nuid_base = 393216\n' >> "$ZONES/router.toml" # The nic zone must be running for a routed zone to attach to it. @@ -1915,12 +1765,10 @@ if (( PRIVILEGED == 1 )) && [[ "${KRYPTIK_VM_DISPOSABLE:-}" == "1" ]]; then else pass "NETR1 the nic zone started and holds the interface" - # Counted from /proc/net/dev (no iproute2 needed), without the kernel - # fallback devices, or sit0 could pass for NETR3's interface. + # From /proc/net/dev, without the fallback devices, or sit0 could pass for NETR3's. zrun router -- /bin/sh -c "$PRO n=\$(sed 1,2d /proc/net/dev | sed 's/:.*//' | tr -d ' ' | grep -vxE '$FALLBACK_RE' | wc -l); r=\$(sed 1d /proc/net/route | wc -l); echo PROBE=if=\$n,routes=\$r" if want_launch "NETR2 a routed zone starts while the nic zone is up"; then - # A plumb failure does not stop the launch, so check the zone was - # plumbed before NETR3 and NETR4 judge the result. + # A plumbing failure does not stop the launch, so NETR3 and NETR4 need this first. if [[ "$ZOUT" == *"has no network path"* ]]; then fail "NETR2b the routed zone was never plumbed, so NETR3/NETR4 measure nothing" info "kryptikd said: $(printf '%s\n' "$ZOUT" | grep -a 'no network path' | head -1)" @@ -1942,8 +1790,7 @@ if (( PRIVILEGED == 1 )) && [[ "${KRYPTIK_VM_DISPOSABLE:-}" == "1" ]]; then fi fi - # Control: with the nic zone up, a mode=none zone still sees only - # loopback, so NETR3 measured routing and not something every zone gets. + # Control: a mode=none zone still sees only loopback, so NETR3 measured routing. zrun alpha -- /bin/sh -c "$PRO n=\$(sed 1,2d /proc/net/dev | sed 's/:.*//' | tr -d ' ' | grep -vxE '$FALLBACK_RE' | wc -l); echo PROBE=\$n" probe "NETR5 control: an airgapped zone still sees only loopback while the nic zone runs" "0" fi @@ -1952,7 +1799,7 @@ if (( PRIVILEGED == 1 )) && [[ "${KRYPTIK_VM_DISPOSABLE:-}" == "1" ]]; then wait "$nicpid" 2>/dev/null "$KRYPTIKD" gc >/dev/null 2>&1 || true elif (( PRIVILEGED == 1 )); then - skip "NETR routed networking [vm] needs a disposable VM: this check MOVES THE PHYSICAL NIC into a zone, and will not do that to a machine it did not build" + skip "NETR routed networking [vm] needs KRYPTIK_VM_DISPOSABLE=1: it moves the physical NIC into a zone" else skip "NETR routed networking [vm] needs root and a disposable VM" fi @@ -1960,19 +1807,12 @@ fi # ============================================================================ head_ "POL. Per-zone policy files [unpriv]" # ============================================================================ -# Checked by what a file changes in the zone, not by what kryptikd says it -# read: a keep-capability shows in the zone's bounding set. + +# Landlock policy files are covered by boundary-checks.sh. mkdir -p "$ZONES/policy" -# A zone whose file keeps one extra capability... cat > "$ZONES/policy/widened.seccomp" <<'POLICY' -# launcher.sh fixture: one directive with an effect that can be seen from -# inside the zone with nothing but /proc/self/status. -# -# CAP_SYS_NICE and not CAP_NET_RAW: the network capabilities may be kept ONLY -# by the zone that owns the NIC, which POL2b checks. This fixture is a -# mode=none zone, so asking for CAP_NET_RAW here is refused - correctly - and -# the check would be measuring that refusal instead of the widening. +# CAP_SYS_NICE, not CAP_NET_RAW: only the zone that owns the NIC may keep that (POL2b). keep-capability CAP_SYS_NICE POLICY mkzone_policy() { # name colour policyfile @@ -1987,6 +1827,7 @@ mkzone_policy() { # name colour policyfile mkzone_policy widened "#0b0b0b" "policy/widened.seccomp" mkzone_policy plainpol "#0c0c0c" "" +# Judged by the zone's bounding set, not by what kryptikd says it read. CAPBND='grep ^CapBnd /proc/self/status | tr -d "\t" | sed s/CapBnd://' zrun plainpol -- /bin/sh -c "$PRO echo PROBE=\$($CAPBND)" @@ -1998,8 +1839,7 @@ zrun widened -- /bin/sh -c "$PRO echo PROBE=\$($CAPBND)" probe "POL2 a policy file's keep-capability reaches the zone's bounding set" \ "0000000000800400" -# Only the zone that owns the NIC may keep the network capabilities; another -# zone asking is refused, naming the rule. +# Another zone asking for a network capability is refused, naming the rule. cat > "$ZONES/policy/netraw.seccomp" <<'POLICY' keep-capability CAP_NET_RAW POLICY @@ -2029,7 +1869,7 @@ else info "output: $(printf '%s' "$ZOUT" | tr '\n' '|' | cut -c1-200)" fi -# And a file must not be able to re-allow something the base policy denies. +# A file must not re-allow something the base policy denies. cat > "$ZONES/policy/escalate.seccomp" <<'POLICY' allow-syscall ptrace POLICY @@ -2041,8 +1881,6 @@ else pass "POL4 a policy file cannot re-allow a syscall the base policy denies" fi -# Per-zone Landlock files are applied; the boundary probes (group E) show one -# narrowing where a zone may write. lp="$("$KRYPTIKD" explain widened --zones "$ZONES" 2>&1 | sed -n 's/^policy *//p' | head -1)" if [[ -n "$lp" ]]; then pass "POL5 explain reports what the policy file adds ($lp)" @@ -2050,8 +1888,7 @@ else fail "POL5 explain does not report the policy file's additions" fi -# seccomp-trace --zone traces under that zone's filter, so a call its policy -# file allows is not reported, and the program gets it. +# seccomp-trace --zone uses that zone's filter: a call its policy allows is not reported. cat > "$ZONES/policy/tracer.seccomp" <<'POLICY' allow-syscall sched_setscheduler POLICY @@ -2072,11 +1909,8 @@ fi # ============================================================================ head_ "LC. Zone lifecycle: registry, stop, concurrency [unpriv]" # ============================================================================ -# The registry is how one kryptikd finds another's zone -# (docs/design/zone-registry.md). Liveness is a lock, not a pid: the launcher -# holds flock(LOCK_EX) on its entry for life, so a crash frees it. The pid is -# kept, with its start time, only so `stop` can signal it and never a reused pid. +# A launcher holds its entry's lock for life, so a crash frees it (docs/design/zone-registry.md). info "registry: $REG" lc_cleanup() { @@ -2085,9 +1919,7 @@ lc_cleanup() { } lc_cleanup -# --- LC1: one instance per zone --------------------------------------------- -# Two launchers of one zone would share a data directory, a cgroup name and, -# for a routed zone, a veth name. +# --- LC1: one instance per zone, or two would share its data, cgroup and veth --- KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run lczone "${ZARGS[@]}" -- /bin/sleep 20 >/dev/null 2>&1 & lc1=$! BG_PIDS+=("$lc1") @@ -2103,7 +1935,6 @@ else info "output: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-200)" fi -# LC8a: while it runs, the registry says so. st="$("$KRYPTIKD" status lczone 2>&1)" if [[ "$st" == *running* ]]; then pass "LC8a status reports a running zone as running" @@ -2112,7 +1943,6 @@ else fail "LC8a status did not report the running zone: $st" fi -# LC10: init.pid is the zone's pid 1: another pid namespace, NSpid ending in 1. initpid="$(awk '{print $1}' "$REG/lczone/init.pid" 2>/dev/null)" if [[ -n "$initpid" ]] && [[ -r "/proc/$initpid/status" ]]; then ourns="$(readlink /proc/self/ns/pid 2>/dev/null)" @@ -2123,11 +1953,7 @@ if [[ -n "$initpid" ]] && [[ -r "/proc/$initpid/status" ]]; then else fail "LC10 init.pid $initpid: ns=$zns (ours $ourns) NSpid=$nspid" fi - # LC17: the zone has a core-scheduling cookie of its own, so a core's - # sibling threads run its tasks or nothing. /proc does not show cookies; - # `kryptikd status` asks the kernel (PR_SCHED_CORE_GET). "no-smt": no core - # has a second thread online (ENODEV, as under nosmt), and the zone must - # still launch. "unavailable": no CONFIG_SCHED_CORE. + # /proc shows no core-scheduling cookie, so `kryptikd status` asks (PR_SCHED_CORE_GET). cs="$("$KRYPTIKD" status lczone 2>/dev/null | grep -o 'core-sched [a-z-]*' | head -1)" case "$cs" in "core-sched own") pass "LC17 the zone's pid 1 has a core-scheduling cookie of its own" ;; @@ -2139,8 +1965,7 @@ else fail "LC10 no usable init.pid in the registry entry" fi -# LC7: the registry names running zones, their identities and cgroups, so only -# the launching uid may read it. +# The registry names running zones, their identities and cgroups: only its uid may read it. mode="$(stat -c '%a %u' "$REG" 2>/dev/null)" if [[ "$mode" == "700 $EUID" ]]; then pass "LC7 the registry is 0700 and owned by the launching uid ($mode)" @@ -2161,7 +1986,6 @@ else fi wait "$lc1" 2>/dev/null -# LC8b: and afterwards it is absent. st="$("$KRYPTIKD" status lczone 2>&1)" [[ "$st" == *absent* ]] && pass "LC8b status reports a stopped zone as absent" \ || fail "LC8b status after stop: $st" @@ -2179,9 +2003,7 @@ else fi wait "$lc9" 2>/dev/null -# --- LC3: a zone that ignores SIGTERM still dies ---------------------------- -# A zone that traps TERM must not keep itself alive: stop escalates to SIGKILL -# after 5 s. +# --- LC3: a zone that ignores SIGTERM still dies: stop sends SIGKILL after 5 s --- KRYPTIK_EXPERIMENTAL=1 "$KRYPTIKD" run lczone "${ZARGS[@]}" -- \ /bin/sh -c 'trap "" TERM; sleep 60' >/dev/null 2>&1 & lc3=$! @@ -2228,8 +2050,6 @@ else fi # --- LC5: a reused pid is never signalled ----------------------------------- -# A stale entry whose recorded pid now belongs to another process, which -# kryptikd must leave alone. lc_cleanup mkdir -p "$REG/lczone" /bin/sleep 25 & victim=$! @@ -2253,26 +2073,19 @@ fi kill -9 "$victim" 2>/dev/null lc_cleanup -# --- LC6: the registry is invisible inside a zone --------------------------- -# A zone has /run/kryptik/broker but must not see /run/kryptik/zones, which -# names every other zone's pid and cgroup. +# --- LC6: the registry, naming every zone's pid and cgroup, is invisible in a zone --- zrun alpha -- /bin/sh -c "$PRO if [ -e /run/kryptik/zones ]; then echo PROBE=VISIBLE; else echo PROBE=absent; fi" probe "LC6 the zone registry is not visible inside a zone" "absent" -# Control: /run/kryptik is there, holding only the broker socket. -zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(ls -A /run/kryptik 2>/dev/null | tr ' -' ',')" +zrun alpha -- /bin/sh -c "$PRO echo PROBE=\$(ls -A /run/kryptik 2>/dev/null | tr '\n' ',')" probe "LC6b control: /run/kryptik is present and contains only the broker socket" "broker," -# LC15: the registry directory must not be plantable. base() can fall back to -# world-writable /tmp, where another user could create or symlink it first. -# XDG_RUNTIME_DIR points base() into $WORK, leaving the real registry alone. -# Unprivileged only: as root base() is /run/kryptik/zones, which nobody else -# can plant, and a plant there would displace the entries of running zones. +# base() can fall back to world-writable /tmp, where another user could plant the registry first. if (( PRIVILEGED == 1 )); then - skip "LC15/LC16 the plantable-registry checks cover the UNPRIVILEGED base path; as root base() is /run/kryptik and ignores XDG_RUNTIME_DIR — the host run exercises them" + skip "LC15/LC16 the plantable-registry checks are unprivileged-only: as root base() is /run/kryptik, whatever XDG_RUNTIME_DIR says" else +# XDG_RUNTIME_DIR moves base() into $WORK, leaving the real registry alone. LC_XDG="$WORK/xdgplant" mkdir -p "$LC_XDG" ln -s "$WORK/elsewhere" "$LC_XDG/kryptik" @@ -2292,7 +2105,6 @@ else fi rm -f "$LC_XDG/kryptik" -# LC16, the control for LC15: the same launch with a real directory starts. mkdir -p "$LC_XDG/kryptik" chmod 0700 "$LC_XDG/kryptik" out="$(XDG_RUNTIME_DIR="$LC_XDG" KRYPTIK_EXPERIMENTAL=1 timeout "$TIMEOUT" \ @@ -2309,8 +2121,8 @@ fi # end of the unprivileged-only LC15/LC16 pair # ============================================================================ head_ "Mandatory checks NOT RUN here" # ============================================================================ -# Named, so the gaps show in the summary. +# Named, so the gaps show in the summary. if (( CGROUP_OK == 0 )); then skip "cgroup memory/pids limits are enforced [vm] this host cannot create cgroups; group M covers it there" fi diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index 1ee7f787..2b9a193c 100755 --- a/compartments/tests/serve.sh +++ b/compartments/tests/serve.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash -# Tests for the launch daemon (`kryptikd serve`), driven over its socket as -# kryptik-launch drives it; launcher.sh covers `kryptikd run` itself. Runs as a -# user (a developer instance serving only this uid) or as root. Needs python3 -# (exit 77 without it). Exits 0 only when every check ran and passed. -# -# A refusal only counts once the daemon is seen answering something else, and -# a launch only once the zone is seen doing what it was asked. +# Launch daemon suite: drives `kryptikd serve` over its socket as kryptik-launch does. +# Runs as a user or root and needs python3 (exit 77 without it); 0 means every check ran and passed. set -uo pipefail @@ -46,8 +41,7 @@ fi WORK="$(mktemp -d)" ZONES="$WORK/zones"; ROOTFS="$WORK/rootfs"; SOCK="$WORK/launch.sock" mkdir -p "$ZONES" "$ROOTFS" -# mktemp -d makes 0700, but a privileged zone's setup runs as the zone's own -# identity and must reach its data directory. +# mktemp -d makes 0700, but a privileged zone's setup reaches its data as the zone's identity. chmod 0755 "$WORK" "$ROOTFS" declare -a BG_PIDS=() cleanup() { @@ -64,8 +58,7 @@ MARK="LAUNCH_OK_$$" # --- fixtures ---------------------------------------------------------------- -# uid_base (65536-aligned, >= 131072) is used by root launches only; an -# unprivileged launch maps to the caller. +# uid_base (65536-aligned, >= 131072) applies to root launches; others map to the caller. mkzone() { # name mode extra-lines uid_base local name="$1" mode="$2" extra="${3:-}" base="$4" { @@ -81,13 +74,13 @@ mkzone alpha none '' 131072 mkzone beta none '' 196608 mkzone carrier nic '' 262144 mkzone broken none $'[policy]\nseccomp = "policy/does-not-exist.seccomp"' 327680 -# An encrypted zone, for `info`; never launched here (that is the volume suite). +# An encrypted zone, only for `info`: launcher.sh group F launches one. { printf '[zone]\nname = "sealed"\ndescription = "serve-suite fixture"\n[network]\nmode = "none"\n' printf '[storage]\nmode = "encrypted"\nvolume = "/dev/kryptik/sealed"\n[identity]\nuid_base = 393216\n[ui]\nborder_color = "#060000"\n' } > "$ZONES/sealed.toml" -# --- the client ---------------------------------------------------------------- +# --- the client -------------------------------------------------------------- cat > "$WORK/client.py" <<'PY' import os, socket, sys, time @@ -132,12 +125,10 @@ while True: c, _ = s.accept() PY -# --- the daemon -------------------------------------------------------------------- +# --- the daemon -------------------------------------------------------------- head_ "daemon" -# --group: the suite's own; root is authorised anyway and a developer instance -# ignores it. --wifi-dir keeps the credentials file in $WORK, so no installed -# one is touched and no real net zone is restarted. +# --wifi-dir in $WORK: no installed credentials file is touched and no net zone restarted. "$KRYPTIKD" serve --zones "$ZONES" --rootfs "$ROOTFS" --socket "$SOCK" --proxy-exe "$WLPROXY" --group "$(id -gn)" --wifi-dir "$WORK/wifi" > "$WORK/serve.log" 2>&1 & DAEMON=$!; BG_PIDS+=("$DAEMON") for _ in $(seq 1 100); do [[ -S "$SOCK" ]] && break; sleep 0.05; done @@ -147,7 +138,7 @@ else fail "S0 the daemon did not start"; sed 's/^/ /' "$WORK/serve.log"; exit 1 fi -# --- requests ----------------------------------------------------------------------- +# --- requests ---------------------------------------------------------------- head_ "requests" r="$(ask 'status\n')" @@ -181,7 +172,7 @@ if [[ "$r" == ok\ /* ]]; then if [[ -d "$d" && "$(stat -c %a "$d")" == 700 ]]; then pass "S3e runtime creates a private runtime directory ($d)"; else fail "S3e runtime dir $d missing or not 0700"; fi else fail "S3e runtime: $r"; fi -# --- the deadline ----------------------------------------------------------------------- +# --- the deadline ------------------------------------------------------------ head_ "a stalled client" python3 "$WORK/client.py" "$SOCK" hold 'run alpha\narg x\n' 12 > "$WORK/hold.out" 2>&1 & @@ -199,7 +190,7 @@ fi wait "$HOLD" 2>/dev/null if grep -q "not completed within" "$WORK/hold.out"; then pass "S4b the stalled client is told its request timed out"; else fail "S4b stalled client got: $(cat "$WORK/hold.out")"; fi -# --- descriptors -------------------------------------------------------------------------- +# --- descriptors ------------------------------------------------------------- head_ "descriptors" printf 'x' > "$WORK/f1"; printf 'y' > "$WORK/f2" @@ -213,11 +204,10 @@ r="$(python3 "$WORK/client.py" "$SOCK" latefd 'run alpha pass=fd\narg /bin/true\ if [[ "$r" == "error: a descriptor must accompany the first bytes"* ]]; then pass "S5d a descriptor arriving after the first bytes is refused"; else fail "S5d: $r"; fi if [[ "$(ask 'status\n')" == "end" ]]; then pass "S5e the daemon still answers after the refusals"; else fail "S5e daemon wedged"; fi -# --- launches ------------------------------------------------------------------------------- +# --- launches ---------------------------------------------------------------- head_ "launches" -# The command truncates its stdout between its two lines. O_APPEND does not -# stop ftruncate, so that stdout must not be the log file itself. +# The zone truncates its stdout, which O_APPEND does not stop, so that must not be the log file. r="$(ask "run alpha\narg /bin/sh\narg -c\narg echo $MARK; python3 -c 'import os; os.ftruncate(1, 0)' 2>/dev/null; echo after-$MARK; sleep 15\nend\n")" if [[ "$r" == ok\ [0-9]* ]]; then pass "S6a run alpha replies ok once the zone is up" @@ -239,8 +229,7 @@ else fail "S6a run alpha: $r"; sed 's/^/ /' "$ZLOG" 2>/dev/null | tail -5 fi -# A zone that ignores SIGTERM is only gone at stop's SIGKILL, seconds later. -# Another client's status, asked meanwhile, must be answered at once. +# A zone ignoring SIGTERM holds stop for seconds; a status asked meanwhile must be answered at once. ms() { date +%s%3N; } r="$(ask "run alpha\narg /bin/sh\narg -c\narg trap '' TERM; while :; do sleep 1; done\nend\n")" if [[ "$r" == ok\ [0-9]* ]]; then @@ -262,8 +251,7 @@ else fail "S6g run alpha (ignoring SIGTERM): $r" fi -# A launcher that ends is reaped then, not at the daemon's next request. It -# runs with PATH alone, plus a developer instance's XDG_RUNTIME_DIR. +# A launcher gets PATH alone, plus XDG_RUNTIME_DIR from a developer instance. r="$(ask "run alpha\narg /bin/sh\narg -c\narg sleep 2\nend\n")" if [[ "$r" == ok\ [0-9]* ]]; then lp="${r#ok }"; lp="${lp%%[!0-9]*}" @@ -297,8 +285,7 @@ else fi for _ in $(seq 1 60); do [[ "$(ask 'status\n')" == "end" ]] && break; sleep 0.1; done -# A command that ends at once with 0 is ok, and its end is in the log however -# quickly it came: a zone terminal whose shell died unseen was a silent "ok". +# An instant exit is ok, but must reach the log, or a zone terminal's dead shell is a silent "ok". r="$(ask 'run alpha\narg /bin/true\nend\n')" for _ in $(seq 1 60); do [[ "$(ask 'status\n')" == "end" ]] && break; sleep 0.1; done if [[ "$r" == ok\ [0-9]* ]] && grep -q "launcher ${r#ok } exited 0" "$WORK/serve.log"; then @@ -307,12 +294,12 @@ else fail "S7d ${r%$'\n'}: $(grep -F "${r#ok }" "$WORK/serve.log" | tr '\n' '|')" fi -# --- the clipboard gesture ------------------------------------------------------------------- -# Two zones up at once, each talking to its own broker at /run/kryptik/broker, -# one request per python3 call (a request without a newline gets one). alpha -# sets a payload and waits for it to leave; beta waits for it to arrive. +# --- the clipboard gesture --------------------------------------------------- + head_ "the clipboard gesture" +# One request to the zone's own broker per python3 call; a request without a newline gets one. BQ="python3 -c 'import socket,sys;r=sys.argv[1];r=r if chr(10) in r else r+chr(10);s=socket.socket(socket.AF_UNIX);s.connect(\"/run/kryptik/broker\");s.sendall(r.encode());s.shutdown(socket.SHUT_WR);sys.stdout.write(s.makefile(\"rb\").read().decode())'" +# alpha sets a payload and waits for it to leave; beta waits for it to arrive. SA="q() { $BQ \"\$1\"; }; q \"\$(printf 'clipboard-set text/plain 5'; echo; printf hello)\"; echo SET-$MARK; i=0; while [ \$i -lt 80 ]; do case \"\$(q clipboard-get)\" in empty*) echo GONE-$MARK; break;; esac; sleep 0.5; i=\$((i+1)); done; sleep 5" SB="q() { $BQ \"\$1\"; }; i=0; while [ \$i -lt 80 ]; do case \"\$(q clipboard-get)\" in ok*hello*) echo GOT-$MARK; break;; esac; sleep 0.5; i=\$((i+1)); done" ra="$(ask "run alpha\narg /bin/sh\narg -c\narg $SA\nend\n")" @@ -334,7 +321,7 @@ else fail "S7e run alpha and beta for the gesture: ${ra%$'\n'} / ${rb%$'\n'}" fi -# --- the proxy socket ------------------------------------------------------------------------ +# --- the proxy socket -------------------------------------------------------- head_ "the proxy socket" r="$(ask 'run alpha wayland=/tmp/wayland-0\narg /bin/true\nend\n')" @@ -388,8 +375,7 @@ else fi for _ in $(seq 1 60); do [[ "$(ask 'status\n')" == "end" ]] && break; sleep 0.1; done - # A symlinked zone directory leading to that valid socket: the path is - # walked without following links. + # A symlinked zone directory to that valid socket: the path is walked without following links. mv "$RT_DIR/alpha" "$RT_DIR/alpha.real"; ln -s "$RT_DIR/alpha.real" "$RT_DIR/alpha" r="$(ask "run alpha wayland=$WL\narg /bin/true\nend\n")" if [[ "$r" == "error: wayland socket path:"* ]]; then pass "S8h a symlink on the way to the socket is refused"; else fail "S8h: $r"; fi @@ -397,11 +383,10 @@ else kill "$PA" 2>/dev/null; wait "$PA" 2>/dev/null fi -# --- the net zone's Wi-Fi credentials -------------------------------------------------- -# One file for the net zone (kryptikd's wifi.rs). The passphrase only travels -# in a request body and never comes back out; a refusal leaves the file as it was. +# --- the net zone's Wi-Fi credentials ---------------------------------------- head_ "wi-fi credentials" +# One file for the net zone (wifi.rs); a passphrase goes in a request body and never comes out. WIFI="$WORK/wifi"; WCONF="$WIFI/wpa_supplicant.conf" r="$(ask 'wifi-list\n')" if [[ "$r" == "end" ]]; then pass "S9a wifi-list with no file is an empty list"; else fail "S9a: $r"; fi @@ -478,7 +463,7 @@ else fi if [[ "$(ask 'status\n')" == "end" ]]; then pass "S9u the daemon still answers"; else fail "S9u daemon wedged"; fi -# --- summary --------------------------------------------------------------------------- +# --- summary ----------------------------------------------------------------- printf '\n%d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP" if (( FAIL > 0 )); then printf 'FAILED:\n'; printf ' %s\n' "${FAILED[@]}"; fi diff --git a/compartments/zones/dev.toml b/compartments/zones/dev.toml index 21b65e05..d0c4d52e 100644 --- a/compartments/zones/dev.toml +++ b/compartments/zones/dev.toml @@ -19,20 +19,17 @@ memory_max = "12G" pids_max = 1024 [identity] -# Host uid/gid range: root inside maps to 131072, nobody to 196606. Fixed, -# never derived from zone order (docs/design/privileged-launch.md). +# Root inside maps to host uid/gid 131072, nobody to 196606; fixed, never derived from zone order. uid_base = 131072 [transfer] -# dev may hand files to work, one way, through the broker and a confirmation -# in zone 0 (docs/design/broker.md). No other shipped zone sends anything. +# dev may hand files to work, one way, after a confirmation in zone 0 (docs/design/broker.md). to = "work" [ui] -# Colour, glyph and label must differ from every other zone's; the pattern -# is checked but nothing draws it. Pick colours with `zoneid propose`; -# `zoneid audit` checks the set, `zoneid explain` the rule. +# Colour, glyph and label must be unique; `zoneid propose` picks colours, `zoneid audit` checks. border_color = "#7f623d" +# The pattern is checked, but nothing draws it. border_pattern = "dotted" glyph = "◆" label = "DEV" diff --git a/compartments/zones/net.toml b/compartments/zones/net.toml index fd662cae..9d35d5dc 100644 --- a/compartments/zones/net.toml +++ b/compartments/zones/net.toml @@ -1,24 +1,18 @@ -# net — the only zone with a route to the physical NIC; every other zone -# reaches the network through it. It is the most exposed zone, so its storage -# is ephemeral (docs/architecture.md, "Known weaknesses"). +# net — the only zone on the physical NIC; the most exposed, so its storage is ephemeral. [zone] name = "net" description = "Sole holder of a physical network route." [network] +# Routed zones reach the network through a veth into kryptik0, this zone's bridge. mode = "nic" -# Zones with mode = "routed" get a veth into kryptik0, the bridge in this zone. -# Interfaces taken out of zone 0 when this zone starts. "*" is every one on a -# bus device (PCI, USB, platform), wireless ones moved by their wiphy; software -# devices (loopback, bridges, veth, tunnels) never qualify. A single name such -# as "eth0" takes only that one. +# Taken from zone 0 at start: "*" is every PCI, USB or platform interface, "eth0" only that one. nic = "*" [storage] mode = "ephemeral" -# Required for ephemeral: $HOME is a tmpfs, and its pages outlive the process -# that wrote them, so the cgroup memory limit does not bound them. +# Required for ephemeral: $HOME is a tmpfs, which could otherwise fill host memory. size = "512M" [policy] @@ -29,15 +23,13 @@ memory_max = "1G" pids_max = 256 [identity] -# Host uid/gid range: root inside maps to 196608, nobody to 262142. Fixed, -# never derived from zone order (docs/design/privileged-launch.md). +# Root inside maps to host uid/gid 196608, nobody to 262142; fixed, never derived from zone order. uid_base = 196608 [ui] -# Colour, glyph and label must differ from every other zone's; the pattern -# is checked but nothing draws it. Pick colours with `zoneid propose`; -# `zoneid audit` checks the set, `zoneid explain` the rule. +# Colour, glyph and label must be unique; `zoneid propose` picks colours, `zoneid audit` checks. border_color = "#686573" +# The pattern is checked, but nothing draws it. border_pattern = "notched" glyph = "◐" label = "NET" diff --git a/compartments/zones/personal.toml b/compartments/zones/personal.toml index d38d3346..9c7583fd 100644 --- a/compartments/zones/personal.toml +++ b/compartments/zones/personal.toml @@ -19,15 +19,13 @@ memory_max = "8G" pids_max = 1024 [identity] -# Host uid/gid range: root inside maps to 262144, nobody to 327678. Fixed, -# never derived from zone order (docs/design/privileged-launch.md). +# Root inside maps to host uid/gid 262144, nobody to 327678; fixed, never derived from zone order. uid_base = 262144 [ui] -# Colour, glyph and label must differ from every other zone's; the pattern -# is checked but nothing draws it. Pick colours with `zoneid propose`; -# `zoneid audit` checks the set, `zoneid explain` the rule. +# Colour, glyph and label must be unique; `zoneid propose` picks colours, `zoneid audit` checks. border_color = "#009b79" +# The pattern is checked, but nothing draws it. border_pattern = "dashed" glyph = "●" label = "PERSONAL" diff --git a/compartments/zones/policy/dev.seccomp b/compartments/zones/policy/dev.seccomp index 0f7e2ea2..80aa6016 100644 --- a/compartments/zones/policy/dev.seccomp +++ b/compartments/zones/policy/dev.seccomp @@ -1,2 +1 @@ -# dev: the shared base policy only. Directives: allow-syscall NAME, allow-socket AF_X, -# allow-netlink NETLINK_X, keep-capability CAP_X (see docs/design/zone-policy-files.md). +# dev: the base policy only; the directives are in docs/design/zone-policy-files.md. diff --git a/compartments/zones/policy/net.seccomp b/compartments/zones/policy/net.seccomp index 7e0ba46e..12dd8f77 100644 --- a/compartments/zones/policy/net.seccomp +++ b/compartments/zones/policy/net.seccomp @@ -1,11 +1,7 @@ -# net: the only zone on a real interface. Its DHCP client and the bridge/NAT -# rules for routed zones (docs/design/net-zone.md) need raw frames, netfilter -# netlink and both network capabilities, all within its own network namespace. -# Directives: docs/design/zone-policy-files.md. +# net: its DHCP client and the routed zones' bridge and NAT need these, within its own netns. allow-socket AF_PACKET allow-netlink NETLINK_NETFILTER keep-capability CAP_NET_ADMIN keep-capability CAP_NET_RAW -# dhcpcd opens a NETLINK_GENERIC socket at start (nl80211 events) and exits -# if refused, leaving routed zones without an uplink. +# dhcpcd exits if refused NETLINK_GENERIC (nl80211 events), leaving routed zones no uplink. allow-netlink NETLINK_GENERIC diff --git a/compartments/zones/policy/personal.seccomp b/compartments/zones/policy/personal.seccomp index 17915084..574a3775 100644 --- a/compartments/zones/policy/personal.seccomp +++ b/compartments/zones/policy/personal.seccomp @@ -1,2 +1 @@ -# personal: the shared base policy only. Directives: allow-syscall NAME, allow-socket AF_X, -# allow-netlink NETLINK_X, keep-capability CAP_X (see docs/design/zone-policy-files.md). +# personal: the base policy only; the directives are in docs/design/zone-policy-files.md. diff --git a/compartments/zones/policy/untrusted.seccomp b/compartments/zones/policy/untrusted.seccomp index 1b7e3295..267ba684 100644 --- a/compartments/zones/policy/untrusted.seccomp +++ b/compartments/zones/policy/untrusted.seccomp @@ -1,2 +1 @@ -# untrusted: the shared base policy only. Directives: allow-syscall NAME, allow-socket AF_X, -# allow-netlink NETLINK_X, keep-capability CAP_X (see docs/design/zone-policy-files.md). +# untrusted: the base policy only; the directives are in docs/design/zone-policy-files.md. diff --git a/compartments/zones/policy/vault.seccomp b/compartments/zones/policy/vault.seccomp index 0560da5f..67f45d99 100644 --- a/compartments/zones/policy/vault.seccomp +++ b/compartments/zones/policy/vault.seccomp @@ -1,2 +1 @@ -# vault: the shared base policy only. Directives: allow-syscall NAME, allow-socket AF_X, -# allow-netlink NETLINK_X, keep-capability CAP_X (see docs/design/zone-policy-files.md). +# vault: the base policy only; the directives are in docs/design/zone-policy-files.md. diff --git a/compartments/zones/policy/work.seccomp b/compartments/zones/policy/work.seccomp index c9a1ed58..6101451b 100644 --- a/compartments/zones/policy/work.seccomp +++ b/compartments/zones/policy/work.seccomp @@ -1,2 +1 @@ -# work: the shared base policy only. Directives: allow-syscall NAME, allow-socket AF_X, -# allow-netlink NETLINK_X, keep-capability CAP_X (see docs/design/zone-policy-files.md). +# work: the base policy only; the directives are in docs/design/zone-policy-files.md. diff --git a/compartments/zones/untrusted.toml b/compartments/zones/untrusted.toml index f1268b33..b53a5c99 100644 --- a/compartments/zones/untrusted.toml +++ b/compartments/zones/untrusted.toml @@ -1,5 +1,4 @@ -# untrusted — for opening things you do not trust. The isolation exit test -# attacks from this zone; its network path is assumed hostile. +# untrusted — for opening things you do not trust; its network path is assumed hostile. [zone] name = "untrusted" @@ -10,8 +9,7 @@ mode = "routed" [storage] mode = "ephemeral" -# Required for ephemeral: $HOME is a tmpfs, and its pages outlive the process -# that wrote them, so the cgroup memory limit does not bound them. +# Required for ephemeral: $HOME is a tmpfs, which could otherwise fill host memory. size = "2G" [policy] @@ -24,15 +22,13 @@ pids_max = 512 cpu_max = "200%" [identity] -# Host uid/gid range: root inside maps to 327680, nobody to 393214. Fixed, -# never derived from zone order (docs/design/privileged-launch.md). +# Root inside maps to host uid/gid 327680, nobody to 393214; fixed, never derived from zone order. uid_base = 327680 [ui] -# Colour, glyph and label must differ from every other zone's; the pattern -# is checked but nothing draws it. Pick colours with `zoneid propose`; -# `zoneid audit` checks the set, `zoneid explain` the rule. +# Colour, glyph and label must be unique; `zoneid propose` picks colours, `zoneid audit` checks. border_color = "#ba7c02" +# The pattern is checked, but nothing draws it. border_pattern = "dash-dot" glyph = "▲" label = "UNTRUSTED" diff --git a/compartments/zones/vault.toml b/compartments/zones/vault.toml index c61d553c..e655f885 100644 --- a/compartments/zones/vault.toml +++ b/compartments/zones/vault.toml @@ -5,8 +5,7 @@ name = "vault" description = "Keys, password store, secrets. No network stack." [network] -# "none": a network namespace with only loopback; there is no interface to -# route through. +# "none": a network namespace with only loopback, so there is no interface to route through. mode = "none" [storage] @@ -21,15 +20,13 @@ memory_max = "2G" pids_max = 128 [identity] -# Host uid/gid range: root inside maps to 393216, nobody to 458750. Fixed, -# never derived from zone order (docs/design/privileged-launch.md). +# Root inside maps to host uid/gid 393216, nobody to 458750; fixed, never derived from zone order. uid_base = 393216 [ui] -# Colour, glyph and label must differ from every other zone's; the pattern -# is checked but nothing draws it. Pick colours with `zoneid propose`; -# `zoneid audit` checks the set, `zoneid explain` the rule. +# Colour, glyph and label must be unique; `zoneid propose` picks colours, `zoneid audit` checks. border_color = "#9e80ac" +# The pattern is checked, but nothing draws it. border_pattern = "double" glyph = "★" label = "VAULT" diff --git a/compartments/zones/work.toml b/compartments/zones/work.toml index 53119730..2ef40fbb 100644 --- a/compartments/zones/work.toml +++ b/compartments/zones/work.toml @@ -19,15 +19,13 @@ memory_max = "8G" pids_max = 1024 [identity] -# Host uid/gid range: root inside maps to 458752, nobody to 524286. Fixed, -# never derived from zone order (docs/design/privileged-launch.md). +# Root inside maps to host uid/gid 458752, nobody to 524286; fixed, never derived from zone order. uid_base = 458752 [ui] -# Colour, glyph and label must differ from every other zone's; the pattern -# is checked but nothing draws it. Pick colours with `zoneid propose`; -# `zoneid audit` checks the set, `zoneid explain` the rule. +# Colour, glyph and label must be unique; `zoneid propose` picks colours, `zoneid audit` checks. border_color = "#2d60d1" +# The pattern is checked, but nothing draws it. border_pattern = "solid" glyph = "■" label = "WORK" From f06a92b04bfbcdeafa95421f0c5c63c40e778ee7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 02:14:25 -0700 Subject: [PATCH 05/14] Cut comments and messages in the source, kernel and hardening checks The supply-chain tools (fetch, provenance, licences, source bundle), the currency, support, kernel and hardening checks, the git hooks and their suites lose their stacked comments; the data files keep one line per column. Essay-like die and warn messages become a line or two, keeping every substring a suite asserts on, and the help ranges still print exactly each synopsis. Code changes: verify-provenance.sh's messages no longer carry embedded newlines and indentation into its report; check-support-status.sh counts covered packages in its main loop instead of a second identical one; the unused PASS_LIST, check-source-currency.sh's never-used work directory and step-errexit.sh's unused yes_/no_ go; literal newlines inside tr arguments are written as \n. verify-signatures.sh, provenance-inventory.sh, key-provenance.tsv and the verify-signatures suite are left out: an open pull request changes them. --- tools/audit-setuid.sh | 24 ++---- tools/check-artifact-hardening.sh | 42 ++++------ tools/check-commit-identity.sh | 9 +- tools/check-image-licences.sh | 6 +- tools/check-kernel-eol.sh | 57 +++++-------- tools/check-kernel-hardening.sh | 25 ++---- tools/check-pin-reviews.sh | 15 +--- tools/check-rust-licences.sh | 13 +-- tools/check-source-currency.sh | 60 ++++--------- tools/check-support-status.sh | 62 +++++--------- tools/currency-rules.tsv | 6 +- tools/fetch-sources.sh | 77 +++++++---------- tools/git-hooks/pre-commit | 17 +--- tools/git-hooks/pre-push | 7 +- tools/install-git-hooks.sh | 25 ++---- tools/pin-reviews.tsv | 13 ++- tools/prune-sources.sh | 5 +- tools/resolve-kernel-config.sh | 20 ++--- tools/run-tests.sh | 10 +-- tools/scan-licenses.sh | 25 ++---- tools/source-bundle.sh | 13 +-- tools/source-notes.tsv | 40 ++------- tools/support-policy.tsv | 50 +++-------- tools/tests/artifact-hardening.sh | 6 +- tools/tests/audit-setuid.sh | 36 +++----- tools/tests/check-kernel-eol.sh | 6 +- tools/tests/check-kernel-hardening.sh | 8 +- tools/tests/check-pin-reviews.sh | 3 +- tools/tests/check-rust-licences.sh | 4 +- tools/tests/check-source-currency.sh | 16 ++-- tools/tests/check-support-status.sh | 11 ++- tools/tests/fetch-sources.sh | 40 ++++----- tools/tests/git-hooks.sh | 38 ++++----- tools/tests/hardening-flags.sh | 26 +++--- tools/tests/image-licences.sh | 5 +- tools/tests/libc-unwind.sh | 28 +++---- tools/tests/provenance-inventory.sh | 19 ++--- tools/tests/rust-lock.sh | 5 +- tools/tests/source-bundle.sh | 6 +- tools/tests/stage-inputs.sh | 12 +-- tools/tests/step-errexit.sh | 66 +++++---------- tools/tests/suite-lib.sh | 5 +- tools/tests/toolchain-identity.sh | 11 +-- tools/tests/verify-provenance.sh | 19 ++--- tools/validate-kernel-config.sh | 9 +- tools/verify-provenance.sh | 116 ++++++++------------------ 46 files changed, 357 insertions(+), 759 deletions(-) diff --git a/tools/audit-setuid.sh b/tools/audit-setuid.sh index 349cd9f7..d8bf3fbb 100755 --- a/tools/audit-setuid.sh +++ b/tools/audit-setuid.sh @@ -1,18 +1,14 @@ #!/usr/bin/env bash -# Fail on any setuid/setgid binary, or any file carrying capabilities, that -# its allowlist does not justify; with --strip, take the bit or the -# capabilities off each one instead (docs/hardening.md, "setuid elimination"). -# Stage 06 strips the image's root. +# Fail on a setuid/setgid bit or file capability its allowlist does not justify (docs/hardening.md). # -# ./tools/audit-setuid.sh [--strip] ROOT +# ./tools/audit-setuid.sh [--strip] ROOT --strip takes each off instead, as stage 06 does source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" STRIP=0 [[ "${1:-}" == --strip ]] && { STRIP=1; shift; } ARG="${1:?usage: audit-setuid.sh [--strip] ROOT}" -# Resolved, so that a trailing slash or a symlink still yields the paths the -# allowlist names; otherwise an allowed binary would be stripped as unknown. +# Resolved, so a trailing slash or a symlink cannot make an allowed binary look unknown. TARGET="$(realpath -e -- "$ARG")" || die "no such path: ${ARG}" [[ -d "$TARGET" ]] || die "not a directory: ${TARGET}" # By device and inode, so a bind mount of / is refused as well as / itself. @@ -21,10 +17,7 @@ TARGET="$(realpath -e -- "$ARG")" || die "no such path: ${ARG}" ALLOWLIST="${KRYPTIK_ROOT}/build/config/setuid-allowlist.txt" CAPLIST="${KRYPTIK_ROOT}/build/config/capability-allowlist.txt" -# listed FILE ARRAY: the paths FILE names into the associative ARRAY, each -# with a justification after it; an entry without one refuses the audit -# before any file is looked at. The last entry counts even without a newline -# after it. +# listed FILE ARRAY: FILE's paths into ARRAY; an entry with no "# why" refuses the audit up front. listed() { local -n into="$2"; local line path [[ -f "$1" ]] || return 0 @@ -42,8 +35,7 @@ listed "$CAPLIST" CAP_ALLOWED # Stripping by a missing or empty list would take the bit off every binary. [[ "$STRIP" -eq 0 || "${#ALLOWED[@]}" -gt 0 ]] || die "--strip needs ${ALLOWLIST} with at least one entry" -# The root's own filesystem only. A directory find cannot read may hold a -# binary, so an unread one fails the audit instead of passing it. +# The root's own filesystem only; a directory find cannot read may hide a binary, so it fails. found="$(mktemp)" find "$TARGET" -xdev -type f -perm /6000 -print0 > "$found" \ || { rm -f "$found"; die "find could not read all of ${TARGET}"; } @@ -104,8 +96,7 @@ for f in "${capped[@]}"; do fi done -# A stripped name that is a hard link to an allowed file took the bit or the -# capabilities off both; the image would ship that binary broken. +# Stripping a hard link to an allowed file strips both; the image would ship it broken. for bin in "${kept[@]}"; do [[ -u "$bin" || -g "$bin" ]] \ || die "${bin#"${TARGET%/}"} lost its bit: a stripped file is another name for it" @@ -117,7 +108,6 @@ done echo if [[ "$violations" -gt 0 ]]; then die "${violations} unjustified setuid/setgid binary(ies) or file capabilities. -Use a kryptikd-brokered service instead. If a binary genuinely needs the -privilege, add it to ${ALLOWLIST} or ${CAPLIST} with a justification." +Use a kryptikd-brokered service, or justify the privilege in ${ALLOWLIST} or ${CAPLIST}." fi ok "no unjustified setuid/setgid binaries or file capabilities" diff --git a/tools/check-artifact-hardening.sh b/tools/check-artifact-hardening.sh index 38efbd0e..b2c8281b 100755 --- a/tools/check-artifact-hardening.sh +++ b/tools/check-artifact-hardening.sh @@ -1,15 +1,13 @@ #!/usr/bin/env bash -# Audit the hardening of the ELF objects a build actually produced. +# Audit the hardening of the ELF objects a build produced. # -# tools/check-artifact-hardening.sh [ROOT] [--strict] [--json FILE] -# [--accepted FILE] +# tools/check-artifact-hardening.sh [ROOT] [--strict] [--json FILE] [--accepted FILE] # -# ROOT defaults to the sysroot for the current KRYPTIK_WORK. RWX, TEXTREL, -# EXEC-STACK and BUILD-RPATH (an rpath into the build tree) always fail. The -# rest are reported, except where the accepted list (by default -# build/config/artifact-accepted.txt) gives the reason. --strict fails on a -# reported finding and on a list entry that matched nothing; without it, a -# sysroot mid-build holds unfinished objects. +# ROOT default: the sysroot +# --strict also fail on reported findings and on accepted entries that matched nothing +# --accepted findings kept, each with a reason; default build/config/artifact-accepted.txt +# +# RWX, TEXTREL, EXEC-STACK and BUILD-RPATH (an rpath into the build tree) always fail. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" @@ -43,8 +41,7 @@ Give a root to inspect, or build one first: have readelf || die "readelf is required (binutils)" -# Not shipped: /tools is the stage 01 cross toolchain, built without hardening -# and deleted before release; /kryptik* are bind-mount points. +# Not shipped: /tools is stage 01's unhardened cross toolchain; /kryptik* are bind-mount points. EXCLUDE_RE='^(tools|kryptik|kryptik-work|kryptik-sources|usr/src|usr/share/doc)(/|$)' log "Artifact hardening audit" @@ -62,9 +59,7 @@ declare -a NO_SSP=() NO_FORTIFY=() bump() { COUNT["$1"]=$(( ${COUNT["$1"]:-0} + 1 )); } -# The accepted list: `FINDING PATH # why`, or `RPATH PATH RPATH # why`, the -# paths being globs. An RPATH entry names the rpath too, since that is what -# it accepts. Only reported findings can be listed, and only with a reason. +# The accepted list: `FINDING GLOB # why`, or `RPATH GLOB RPATH # why` naming the rpath accepted. declare -a ACC_KIND=() ACC_PATH=() ACC_RPATH=() ACC_USED=() ACC_LINE=() if [[ -n "$ACCEPTED" ]]; then n=0 @@ -132,7 +127,7 @@ audit_one() { kind=lib; LIBS=$((LIBS + 1)) fi - # Hard failures. First, a LOAD segment carrying all three permission bits. + # Hard failures, starting with a LOAD segment that carries all three permission bits. if awk '/^ +LOAD/ { if ($0 ~ /RWE/) exit 1 } END { exit 0 }' <<<"$out"; then :; else hard "RWX" "$rel" fi @@ -178,9 +173,7 @@ audit_one() { grep -qE 'IBT|SHSTK' <<<"$out" || soft "NO-CET" "$rel" - # Counted, never failed: a function without a local array gets no canary - # and a call with no known size no _chk variant, so an object with neither - # shows nothing about its flags. The record names them. + # Counted, never failed: code with no local arrays or sized calls shows neither, whatever its flags. if grep -q '__stack_chk_fail' <<<"$out"; then bump "HAS-SSP"; else NO_SSP+=("$rel"); fi if grep -qE '__[a-z_]+_chk@|__[a-z_]+_chk$' <<<"$out"; then bump "HAS-FORTIFY"; else NO_FORTIFY+=("$rel"); fi } @@ -195,8 +188,7 @@ done < <(find "$ROOT" -xdev -type f -print0 2>/dev/null) echo if [[ "$TOTAL" -eq 0 ]]; then - warn "no ELF objects found under ${ROOT}" - warn "Either the build has not produced anything yet, or ROOT is wrong." + warn "no ELF objects found under ${ROOT}: nothing built yet, or the wrong ROOT" exit 1 fi @@ -250,7 +242,7 @@ if [[ -n "$JSON" ]]; then printf ' "findings": {\n' sep="" for k in "${!COUNT[@]}"; do - # A real newline: through %s, "\n" would print as two characters. + # $',\n' holds a newline; "\n" through %s would print as two characters. printf '%s "%s": %d' "$sep" "$k" "${COUNT[$k]}" sep=$',\n' done @@ -280,11 +272,9 @@ fi echo if [[ "$HARD" -gt 0 ]]; then - die "${HARD} object(s) failed a check that has no legitimate explanation. - -Do not fix these by removing flags from build/config/hardening.env. Find the -package, read its link line in ${KRYPTIK_WORK}/logs, and either correct how it -takes LDFLAGS or add a justified per-package exception." + die "${HARD} object(s) failed a hard check. +Do not remove flags from build/config/hardening.env: read the package's link line in +${KRYPTIK_WORK}/logs and fix how it takes LDFLAGS, or add a justified per-package exception." fi if [[ "$STRICT" -eq 1 && "${#SOFT_LINES[@]}" -gt 0 ]]; then die "${#SOFT_LINES[@]} reported finding(s), and --strict was requested." diff --git a/tools/check-commit-identity.sh b/tools/check-commit-identity.sh index 3679fd04..dbf78455 100755 --- a/tools/check-commit-identity.sh +++ b/tools/check-commit-identity.sh @@ -1,12 +1,9 @@ #!/usr/bin/env bash -# Check that commits are authored and committed by the one permitted identity. -# This is the only place it is written; both hooks and CI run this script. +# Check that commits are authored and committed by the one permitted identity (hooks and CI run it). # # tools/check-commit-identity.sh --pending what the next commit would record # tools/check-commit-identity.sh every commit in the range # tools/check-commit-identity.sh every commit on every ref -# -# No $(...) in the per-commit loop: a fork per commit is slow on Windows. set -uo pipefail @@ -54,10 +51,10 @@ else sep=$'\x1f' checked=0 excepted=0 + # No $(...) in this loop: a fork per commit is slow on Windows. while IFS="$sep" read -r sha an ae cn ce; do [[ -z "$sha" ]] && continue - # PR #130 was merged by GitHub, which wrote this immutable merge - # commit with platform identities. Future commits still need ours. + # A merge commit GitHub wrote under its own identities, which cannot be rewritten. if [[ "$sha" == f7fe5749efe77fe43c889d8b1459d190c03a4832 ]]; then excepted=$((excepted + 1)) continue diff --git a/tools/check-image-licences.sh b/tools/check-image-licences.sh index a60e49d6..21f01885 100755 --- a/tools/check-image-licences.sh +++ b/tools/check-image-licences.sh @@ -1,8 +1,5 @@ #!/usr/bin/env bash -# Every source has its licence files under ROOT/usr/share/licenses// -# (stage 04's s_licences installs them), and Kryptik has its own. A source -# the image carries no licence file for needs a line, with its reason, in -# build/config/licence-exceptions.txt. +# Checks ROOT/usr/share/licenses/ holds Kryptik's licence and each source's, or its exception line. # # ./tools/check-image-licences.sh ROOT @@ -20,7 +17,6 @@ if [[ -f "$EXC" ]]; then done < "$EXC" fi -# A directory with at least one file in it. has_files() { [[ -d "$1" && -n "$(find "$1" -type f -print -quit)" ]]; } missing=(); n=0 diff --git a/tools/check-kernel-eol.sh b/tools/check-kernel-eol.sh index d1e009a4..3e0e45f9 100755 --- a/tools/check-kernel-eol.sh +++ b/tools/check-kernel-eol.sh @@ -18,8 +18,7 @@ done RELEASES_URL="https://www.kernel.org/releases.json" -# Test hook for tools/tests/check-kernel-eol.sh. It needs a second variable so a -# stray one cannot point the gate at a feed other than kernel.org. +# Test hook; it needs a second variable, so a stray one cannot point the gate off kernel.org. if [[ -n "${KRYPTIK_KERNEL_RELEASES_URL:-}" ]]; then [[ "${KRYPTIK_KERNEL_EOL_SELFTEST:-0}" == "1" ]] || die \ "KRYPTIK_KERNEL_RELEASES_URL is set but KRYPTIK_KERNEL_EOL_SELFTEST is not. @@ -45,8 +44,7 @@ if ! curl -fsL --max-time 30 -o "$RELEASES" "$RELEASES_URL"; then rm -f "$RELEASES" if [[ "$STRICT" -eq 1 ]]; then err "could not reach ${RELEASES_URL}" - die "Kernel support status could not be established (ADR-009). ---strict will not pass an unverified kernel pin. Re-run with network access." + die "Kernel support status could not be established (ADR-009); re-run with network access." fi warn "could not reach ${RELEASES_URL}; kernel support status UNKNOWN" warn "this is not a pass: --strict fails here. Re-run with network access" @@ -58,9 +56,7 @@ classify() { "$PY_BIN" - "$RELEASES" "$V_LINUX" <<'PYEOF' import json, sys -# Monikers kernel.org actually publishes. Anything else means this script is -# looking at a feed it does not understand, which is not the same as a -# supported kernel. +# Monikers kernel.org publishes; any other means a feed this does not understand. KNOWN = {"mainline", "stable", "longterm", "linux-next"} @@ -146,8 +142,7 @@ if not same_series: "longterm series: %s" % (series, ", ".join(lts) or "none listed")) -# No exact entry: kernel.org lists only the current release of each series, so -# the authority for a pin that is behind is the newest entry in ITS series. +# kernel.org lists only each series' current release, so a pin behind it is judged by that release. newest = max(same_series, key=lambda r: parts(r.get("version"))) check(newest, "series %s, represented by %s" % (series, newest.get("version"))) @@ -166,16 +161,14 @@ rc=0 result="$(classify)" || rc=$? if [[ "$rc" -ne 0 || -z "$result" ]]; then err "the kernel classifier failed (exit ${rc})" - die "Kernel support status could not be established (ADR-009). -This is a defect in tools/check-kernel-eol.sh, not a passing check." + die "Kernel support status could not be established (ADR-009): a defect in this tool, not a pass." fi status="$(printf '%s\n' "$result" | head -1)" message="$(printf '%s\n' "$result" | tail -n +2)" -BUMP_ADVICE="Pick a current 'longterm' release from -https://www.kernel.org/releases.json and update V_LINUX in -build/config/versions.env, along with V_LINUX_HARDENED to match." +BUMP_ADVICE="Pin a current longterm release from https://www.kernel.org/releases.json +as V_LINUX in build/config/versions.env, with V_LINUX_HARDENED to match." case "$status" in OK) @@ -183,50 +176,41 @@ case "$status" in ;; STALE) warn "$message" - warn "Still longterm and supported, but a point release is available." - warn "Bump when convenient; this is not a security failure." + warn "A point release is available: bump when convenient; this is not a security failure." ;; EOL) err "$message" - die "The pinned kernel receives no security updates. -This is disqualifying for a security distribution (ADR-009). + die "The pinned kernel receives no security updates (ADR-009). ${BUMP_ADVICE}" ;; NOTLTS) err "$message" - die "Kryptik pins longterm kernels only (ADR-009). -A non-longterm kernel reaches EOL within roughly two months of release. + die "Kryptik pins longterm kernels only (ADR-009); others reach EOL within months. ${BUMP_ADVICE}" ;; ABSENT) err "$message" - die "A kernel whose series upstream no longer lists cannot be shown to -be supported, and an unsupported kernel is disqualifying (ADR-009). + die "A series kernel.org no longer lists cannot be shown to be supported (ADR-009). ${BUMP_ADVICE}" ;; AHEAD) err "$message" - die "The pin matches no release kernel.org lists, so its support status -cannot be checked. Verify V_LINUX is not a typo. + die "The pin is newer than any release kernel.org lists; check V_LINUX for a typo. ${BUMP_ADVICE}" ;; UNKNOWN) err "$message" - die "Unknown support status is not supported status (ADR-009). -Either kernel.org changed the shape of releases.json - in which case -tools/check-kernel-eol.sh needs updating - or the pin is in a state this -check deliberately refuses to guess about." + die "Unknown support status is not support (ADR-009). +If kernel.org changed releases.json, update tools/check-kernel-eol.sh." ;; MALFORMED) err "$message" - die "The release feed could not be parsed, so the pinned kernel's -support status is unknown. That is a failure, not a pass: re-run, and if -kernel.org has changed the format, update tools/check-kernel-eol.sh." + die "The release feed could not be parsed, so support status is unknown. +Re-run; if kernel.org changed the format, update tools/check-kernel-eol.sh." ;; *) err "unrecognised classifier status: ${status}" - die "tools/check-kernel-eol.sh could not interpret its own result. -Treat the kernel pin as unverified." + die "tools/check-kernel-eol.sh could not interpret its own result; the kernel pin is unverified." ;; esac @@ -236,12 +220,9 @@ if [[ -n "${V_LINUX_HARDENED:-}" ]]; then ok "linux-hardened ${V_LINUX_HARDENED} matches the pinned kernel" else err "V_LINUX_HARDENED (${V_LINUX_HARDENED}) does not match V_LINUX (${V_LINUX})" - die "The linux-hardened patch is version-specific and will not apply. -Find the matching release at -https://github.com/anthraxx/linux-hardened/releases" + die "Pin the matching release from https://github.com/anthraxx/linux-hardened/releases" fi else err "V_LINUX_HARDENED is unset" - die "The hardened kernel patch is how Kryptik's kernel is hardened; an -unset pin means the check above validated a kernel Kryptik does not build." + die "Kryptik builds its kernel with linux-hardened; pin the release matching V_LINUX." fi diff --git a/tools/check-kernel-hardening.sh b/tools/check-kernel-hardening.sh index 6fdd1d40..63a98f43 100755 --- a/tools/check-kernel-hardening.sh +++ b/tools/check-kernel-hardening.sh @@ -1,21 +1,15 @@ #!/usr/bin/env bash -# Run kernel-hardening-checker on a resolved kernel config and the shipped -# command line, and hold the result to a list of accepted failures. +# Run kernel-hardening-checker on a kernel config and command line; each FAIL is fixed or accepted. # # ./tools/check-kernel-hardening.sh --config FILE [--cmdline FILE] # [--accepted FILE] [--checker-dir DIR] # -# --config FILE the resolved .config (from stage 05 or -# tools/resolve-kernel-config.sh) -# --cmdline FILE a one-line kernel command line; default: the shipped -# shape, built from COMMON_ARGS in build/stages/06-iso.sh +# --config FILE the resolved .config (stage 05 or tools/resolve-kernel-config.sh) +# --cmdline FILE a one-line command line; default: built from COMMON_ARGS in stage 06 # --accepted FILE default build/config/kernel/checker-accepted.txt -# --checker-dir DIR an unpacked checker tree; default: the pinned release, -# unpacked from KRYPTIK_SOURCES on demand +# --checker-dir DIR an unpacked checker; default: the pinned release from KRYPTIK_SOURCES # -# Each FAIL must be fixed or listed, with its reason, in the accepted file; an -# accepted option that passes now is reported stale. Exit 1 on an unaccepted -# failure, a malformed accepted list, or a checker that could not run. +# Exit 1 on an unaccepted failure, a malformed accepted list or a checker that could not run. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config @@ -30,7 +24,7 @@ while [[ $# -gt 0 ]]; do --cmdline) CMDLINE="${2:?--cmdline needs a file}"; shift 2 ;; --accepted) ACCEPTED="${2:?--accepted needs a file}"; shift 2 ;; --checker-dir) CHECKER_DIR="${2:?--checker-dir needs a directory}"; shift 2 ;; - -h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,12p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -62,8 +56,6 @@ KHC="${CHECKER_DIR}/bin/kernel-hardening-checker" run_khc() { PYTHONPATH="$CHECKER_DIR" python3 "$KHC" "$@"; } log "kernel-hardening-checker $(run_khc --version 2>&1 | awk '{print $NF}')" -# Without --cmdline, build one of the shipped shape. Stage 06 writes the real -# ones; their hardening-relevant part is COMMON_ARGS, the rest names the root. CMDLINE_TMP="" JSON_TMP="" # shellcheck disable=SC2317 # reached through the EXIT trap below @@ -73,6 +65,7 @@ cleanup() { return 0 } trap cleanup EXIT +# Without --cmdline, use stage 06's COMMON_ARGS; the rest of a shipped line only names the root. if [[ -z "$CMDLINE" ]]; then stage06="${KRYPTIK_ROOT}/build/stages/06-iso.sh" common="$(sed -n 's/^COMMON_ARGS="\([^"]*\)"$/\1/p' "$stage06" | head -1)" @@ -92,10 +85,10 @@ echo JSON="$(run_khc -c "$CONFIG" -l "$CMDLINE" -m json 2>/dev/null)" \ || die "the checker failed on ${CONFIG}" -# Hold the failures to the accepted list; counts come back on the last line. -# The JSON goes through a file because the heredoc already takes stdin. +# A file, as the heredoc below takes stdin. JSON_TMP="$(mktemp)" printf '%s' "$JSON" > "$JSON_TMP" +# Hold the failures to the accepted list; the counts come back on the last line. result="$(python3 - "$ACCEPTED" "$JSON_TMP" <<'PY' import json, sys accepted_path, json_path = sys.argv[1], sys.argv[2] diff --git a/tools/check-pin-reviews.sh b/tools/check-pin-reviews.sh index 80027d01..87e25f87 100755 --- a/tools/check-pin-reviews.sh +++ b/tools/check-pin-reviews.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash -# Every pin that is behind its upstream needs a row in tools/pin-reviews.tsv: -# -# package pinned reviewed_up_to fine|held date what was read, and why -# -# A row lapses when the pin moves or upstream releases past reviewed_up_to. -# `held` means a known fix is not taken, for the reason in the note; --no-held -# (for a release) refuses it. Reads a survey, never the network: +# Fail unless every pin behind its upstream has a current review in tools/pin-reviews.tsv. +# A review lapses when the pin moves or upstream releases past it; --no-held refuses held pins. # # tools/check-source-currency.sh --tsv > survey.tsv # tools/check-pin-reviews.sh --survey survey.tsv [--reviews FILE] [--no-held] @@ -27,8 +22,7 @@ same() { [[ "$1" == "$2" ]] && return 0 [[ "$1" =~ ^[0-9a-f]{12,40}$ && "$2" =~ ^[0-9a-f]{12,40}$ && ( "$1" == "$2"* || "$2" == "$1"* ) ]] } -# Versions have an order; two commit IDs have none, so any other commit than -# the one reviewed is new. +# Commit IDs have no order, so any commit other than the one reviewed is new. newer() { ! same "$1" "$2" || return 1 [[ "$1" =~ ^[0-9a-f]{12,40}$ && "$2" =~ ^[0-9a-f]{12,40}$ ]] && return 0 @@ -47,8 +41,7 @@ while read -r pkg pinned upto verdict _date note; do fi done < "$REVIEWS" -# Tabs become a separator that is not whitespace: read merges a run of tabs, so -# an empty "newest" column would shift every field after it. +# read merges a run of tabs, so an empty "newest" would shift the fields; \037 is not whitespace. while IFS=$'\037' read -r name pinned newest status _; do [[ -n "$name" ]] || continue SEEN[$name]=1 diff --git a/tools/check-rust-licences.sh b/tools/check-rust-licences.sh index 719ce653..0c5adfb6 100755 --- a/tools/check-rust-licences.sh +++ b/tools/check-rust-licences.sh @@ -1,15 +1,9 @@ #!/usr/bin/env bash -# The licence texts build/licences carries for what kryptikd and -# kryptik-wlproxy link are the ones the pinned Rust release and the locked -# crates carry: Rust's own files and its library's COPYRIGHT-library.html, -# each locked crate's licence files, and the COPYRIGHT of the musl that -# rust-std's musl target links. +# Checks build/licences against the Rust release, crates and musl in kryptikd and kryptik-wlproxy. # # ./tools/check-rust-licences.sh DIST # -# DIST holds the tarballs build/config/rust.lock pins, unpacked (the Distro -# workflow's rust-dist). The crates are read from the cargo registry they were -# fetched into, so this runs after the build. +# DIST holds the unpacked rust.lock tarballs; run after the build, which fills the cargo registry. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" @@ -41,8 +35,7 @@ if ! cmp -s "${rustc}/rustc/share/doc/rust/COPYRIGHT-library.html" "$lib"; then bad=$((bad + 1)) fi -# Each crate the shipped binaries' lockfiles fetch has build/licences/rust-NAME -# with exactly its licence files, and no other crate has one. +# Every locked crate, and no other, has build/licences/rust-NAME with exactly its licence files. declare -A locked=() while read -r name ver; do locked[$name]=1 diff --git a/tools/check-source-currency.sh b/tools/check-source-currency.sh index 94f2aeb8..614d964a 100755 --- a/tools/check-source-currency.sh +++ b/tools/check-source-currency.sh @@ -8,9 +8,7 @@ # ./tools/check-source-currency.sh --strict also fail on UNKNOWN # ./tools/check-source-currency.sh --tsv machine-readable -# Every "newest" comes from the project's own host: a listing or its designated -# latest release. Anything that does not parse is UNKNOWN, never "current". -# Each manifest row's new column says where to look (tools/fetch-sources.sh). +# Every "newest" comes from the project's own host; anything unparsed is UNKNOWN, never "current". source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config @@ -33,9 +31,6 @@ done have curl || die "curl required" have python3 || die "python3 required" -WORK="${KRYPTIK_WORK}/currency" -rm -rf "$WORK"; mkdir -p "$WORK" - # Test hook: tools/tests/check-source-currency.sh serves listings on 127.0.0.1. if [[ -n "${KRYPTIK_CURRENCY_BASE:-}" ]]; then [[ "${KRYPTIK_CURRENCY_SELFTEST:-0}" == "1" ]] || die \ @@ -54,13 +49,10 @@ resolve() { fi } -# `|| true`: a failed fetch must give an empty answer (UNKNOWN), not trip -# common.sh's ERR trap and abort the run. +# A failed fetch is an empty answer (UNKNOWN), not an abort by common.sh's ERR trap. fetch() { curl -fsSL --max-time 25 "$(resolve "$1")" 2>/dev/null || true; } -# versions_in_listing -# Every version offered, pre-releases dropped, in version order. The ERE goes -# into sed with "@" as the delimiter (patterns match a trailing "/"), so no "@". +# versions_in_listing URL ERE: releases in version order; the ERE has one group and no "@". versions_in_listing() { fetch "$1" \ | grep -oE "$2" \ @@ -69,13 +61,10 @@ versions_in_listing() { | sort -V -u || true } -# Separate from versions_in_listing so Perl's filter can run before the maximum. +# Separate from versions_in_listing so a rule's keep filter can run before the maximum. newest_in_listing() { versions_in_listing "$@" | tail -1; } -# The project's designated latest release, not the highest tag (expat has a -# CVS-era "V20000512"). No releases.atom fallback: without Releases it lists -# tags, newest first, which gave shadow a "3.3.1" that is not shadow's. -# Unauthenticated, the API allows 60 requests an hour; set GH_TOKEN. +# The designated latest release, not the highest tag (expat's "V20000512") nor releases.atom's tags. newest_github_release() { local auth=() [[ -n "${GH_TOKEN:-}" ]] && auth=(-H "Authorization: Bearer ${GH_TOKEN}") @@ -96,9 +85,7 @@ m = re.search(r'(\d+[\d._]*\d)', tag.replace('_', '.')) print(m.group(1) if m else '')" || true } -# Every value of one string key in a JSON response, leading "v" dropped. The key -# is matched with its opening quote ("name" is not "author_name") and, for -# "name", the object's opening brace, so a nested "name" is skipped. +# Each value of a JSON string key, "v" dropped; "name" must open its object, so nested ones are skipped. api_values() { # api_values URL KEY local open='"' [[ "$2" == name ]] && open='\{"' @@ -106,11 +93,10 @@ api_values() { # api_values URL KEY | sed -E "s/.*\"$2\":\"v?([^\"]*)\"/\1/" || true } -# The highest all-numeric version (0.8-dev and 4.0.7rc1 are dropped). Sorted, -# since GitLab lists releases by date, not version. +# The highest all-numeric version (not 0.8-dev or 4.0.7rc1), sorted: GitLab lists by date. numeric_newest() { grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V -u | tail -1 || true; } -# keep : the versions a rule lets the pin move to, in order. +# keep POLICY PINNED: the versions a rule lets the pin move to, in order. keep() { local s case "$1" in @@ -126,7 +112,7 @@ keep() { # --- rules ------------------------------------------------------------------ -# Where a row that declares "rule" looks, beside the manifest it serves. +# Where a manifest row that declares "rule" looks. RULES="${KRYPTIK_ROOT}/tools/currency-rules.tsv" declare -A R_SHAPE=() R_WHERE=() R_MATCH=() R_KEEP=() @@ -168,7 +154,7 @@ A rule that cannot be read is a tooling fault: its row would read UNKNOWN." load_rules -# rule_newest prints "|". +# rule_newest NAME PINNED: "newest|consulted". rule_newest() { local name="$1" pinned="$2" newest="" consulted if [[ -z "${R_SHAPE[$name]:-}" ]]; then @@ -193,16 +179,14 @@ rule_newest() { # --- per-source strategy ---------------------------------------------------- -# upstream_for prints "|"; -# either may be empty. new is the manifest's column (tools/fetch-sources.sh). +# upstream_for NAME PINNED URL NEW (the manifest's column): "newest|consulted", either may be empty. upstream_for() { local name="$1" pinned="$2" url="$3" new="$4" local dir="${url%/*}" base="${url##*/}" local newest="" consulted="" stem="${base%%-[0-9]*}" case "$new" in - # A longterm kernel is not "behind" a newer series; check-kernel-eol.sh - # checks its support status instead. + # A longterm kernel is not "behind" a newer series; check-kernel-eol.sh checks its support. eol) consulted="tools/check-kernel-eol.sh (support status, not version)" ;; follows:*) @@ -220,8 +204,7 @@ upstream_for() { "${name}-([0-9]+(\.[0-9]+)+)\.tar\.(xz|gz)")" ;; - # A vN/ directory offers only its own series, so the parent's newest - # vN/ is read first. + # A vN/ directory offers only its own series, so the parent's newest vN/ is read first. vdir) local parent="${dir%/*}" local vdir @@ -310,17 +293,14 @@ while read -r name pinned url _ new; do fi done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) -# glibc is its tarball plus upstream's release branch up to one commit, which -# the patch set's name records (build/patches/glibc-*/0001-release-*-). -# The branch head, from sourceware's own git, says whether the branch moved on. +# glibc's patch set carries its release branch up to the commit its name ends in; check the head. glibc_patch=("${KRYPTIK_ROOT}/build/patches/glibc-${V_GLIBC:-none}"/0001-release-*.patch) if [[ -f "${glibc_patch[0]}" && ( -z "$ONLY" || "$ONLY" == glibc-branch ) ]]; then name=glibc-branch pinned="${glibc_patch[0]##*-}"; pinned="${pinned%.patch}" ref="refs/heads/release/${V_GLIBC}/master" consulted="https://sourceware.org/git/glibc.git ${ref}" - # From /: ls-remote needs no repository, and whatever the current one is - # (a worktree git cannot open) must not decide the answer. + # From /: ls-remote needs no repository, and a current one git cannot open must not decide. newest="$(GIT_TERMINAL_PROMPT=0 timeout 30 git -C / ls-remote "$(resolve https://sourceware.org/git/glibc.git)" "$ref" 2>/dev/null \ | cut -c1-40 | head -1 || true)" if [[ -z "$newest" ]]; then @@ -356,17 +336,13 @@ if [[ "$BEHIND" -gt 0 ]]; then fi if [[ "$UNKNOWN" -gt 0 ]]; then echo - dim "Could not be determined - the row names no way to look this script" - dim "knows, or upstream's answer held nothing that parses as a version:" + dim "Not determined (no known way to look, or no version in upstream's answer):" printf ' - %s\n' "${UNKNOWN_LIST[@]}" fi echo -dim "Being behind is not automatically a defect: xz is pinned well clear of the" -dim "CVE-2024-3094 window on purpose, and a major bump can change build" -dim "behaviour. Read this with build/config/versions.env open." -dim "For the kernel, support status rather than version number is the question:" -dim "tools/check-kernel-eol.sh." +dim "Being behind is not automatically a defect: a major bump can change the build." +dim "For the kernel, support status is the question: tools/check-kernel-eol.sh." rc=0 if [[ "$FAIL_BEHIND" -eq 1 && "$BEHIND" -gt 0 ]]; then diff --git a/tools/check-support-status.sh b/tools/check-support-status.sh index ba06d331..d63112a3 100755 --- a/tools/check-support-status.sh +++ b/tools/check-support-status.sh @@ -10,8 +10,6 @@ source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# One row per series, with the upstream page and the date it was read; the -# file's header documents the format. POLICY="${TOOLS_DIR}/support-policy.tsv" VERSIONS="${KRYPTIK_ROOT}/build/config/versions.env" STRICT=0 @@ -87,9 +85,7 @@ note() { dim "$*" } -[[ -f "$POLICY" ]] || die "no policy file at ${POLICY} -This check cannot establish anything without it, and reporting nothing as -though it were a pass is the failure mode this tool exists to prevent." +[[ -f "$POLICY" ]] || die "no policy file at ${POLICY}; this check cannot establish anything without it" declare -a P_PKG=() P_VAR=() P_SER=() P_ST=() P_END=() P_URL=() P_RET=() P_NOTE=() lineno=0 @@ -118,11 +114,10 @@ while IFS= read -r line || [[ -n "$line" ]]; do valid_day "$f_end" || valid_month "$f_end" \ || bad="support_ends '${f_end}' is neither YYYY-MM-DD, YYYY-MM nor -" fi - # Some upstreams publish a tier, not a date (perl supports its two newest - # series). Such a row never expires, so its note must name the tier. + # A tier instead of a date (perl's two newest series) never expires, so the note must name it. if [[ -z "$bad" && "$f_st" == "supported" && "$f_end" == "-" ]]; then case "${f_note// }" in - ""|"-") bad="a 'supported' row with no support_ends date must record in its note the support tier upstream published" ;; + ""|"-") bad="a 'supported' row with no support_ends date must name upstream's tier in its note" ;; esac fi if [[ -z "$bad" ]]; then @@ -152,9 +147,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do done < "$POLICY" if [[ "$malformed" -gt 0 ]]; then - die "${malformed} malformed row(s) in ${POLICY}. -A policy file that cannot be parsed is a tooling fault, not a support result: -nothing here has been checked, in either mode." + die "${malformed} malformed row(s) in ${POLICY}; nothing here has been checked" fi [[ "${#P_PKG[@]}" -gt 0 ]] || die "${POLICY} contains no rows" @@ -193,11 +186,13 @@ hard_fail=0 # false support: fails in both modes soft_fail=0 # cannot be established: fails --strict only declare -a EOL_LINES=() seen="" +covered=0 for i in "${!P_PKG[@]}"; do pkg="${P_PKG[$i]}" case " ${seen} " in *" ${pkg} "*) continue ;; esac seen="${seen} ${pkg}" + covered=$((covered + 1)) var="${P_VAR[$i]}" pin="$(pinned_version "$var")" @@ -231,9 +226,8 @@ for i in "${!P_PKG[@]}"; do n_offmap=$((n_offmap + 1)) soft_fail=$((soft_fail + 1)) row FAIL "${pkg} ${pin}: series ${ser} is OFF THE POLICY MAP" - note " ${POLICY} has rows for ${pkg} but none for ${ser}, so" - note " whether it is still supported has not been established." - note " Read the series off ${P_URL[$i]} and add the row." + note " ${POLICY} has rows for ${pkg} but none for ${ser};" + note " read the series off ${P_URL[$i]} and add the row." continue fi @@ -256,8 +250,7 @@ for i in "${!P_PKG[@]}"; do n_deleg=$((n_deleg + 1)) soft_fail=$((soft_fail + 1)) row FAIL "${pkg} ${pin}: delegated to ${tool}, which is MISSING" - note " A delegation to a tool that is not there is a skipped" - note " check, and a skipped check is not a pass." + note " a skipped check is not a pass" else n_deleg=$((n_deleg + 1)) row "NOT CHECKED" "${pkg} ${pin}: established by ${tool:-another tool}, not here" @@ -283,8 +276,8 @@ for i in "${!P_PKG[@]}"; do n_eol=$((n_eol + 1)) hard_fail=$((hard_fail + 1)) row FAIL "${pkg} ${pin}: series ${ser} went END OF LIFE on ${eff}${prec}" - note " $(days_between "$eff" "$NOW") days ago. It will receive no further" - note " security fixes. Source: ${url} (read ${ret})" + note " $(days_between "$eff" "$NOW") days ago; it receives no further security fixes." + note " Source: ${url} (read ${ret})" EOL_LINES+=("${pkg} ${pin} - series ${ser} unsupported since ${eff}") elif [[ "$st" == "security-only" ]]; then n_sec=$((n_sec + 1)) @@ -299,8 +292,8 @@ for i in "${!P_PKG[@]}"; do # Tier-based: ok, but say that the row cannot expire. n_ok=$((n_ok + 1)) row ok "${pkg} ${pin}: series ${ser} supported - ${nte}" - note " upstream publishes a tier and no end date, so this row" - note " cannot expire by itself; re-read ${url} when the pin moves" + note " a tier with no end date: this row cannot expire by itself" + note " re-read ${url} when the pin moves" else n_ok=$((n_ok + 1)) row ok "${pkg} ${pin}: series ${ser} supported until ${eff}${prec}" @@ -309,27 +302,15 @@ for i in "${!P_PKG[@]}"; do esac done -covered=0 -seen="" -for i in "${!P_PKG[@]}"; do - pkg="${P_PKG[$i]}" - case " ${seen} " in *" ${pkg} "*) continue ;; esac - seen="${seen} ${pkg}" - covered=$((covered + 1)) -done - printf '\n' >> "$BUF" log "Summary" note " ${n_ok} supported, ${n_sec} security-fixes-only, ${n_eol} END OF LIFE," note " ${n_offmap} off the policy map, ${n_deleg} established elsewhere," note " ${n_nopol} with no published window, ${n_unpinned} not pinned." note "" -note " COVERAGE FLOOR: ${covered} of ${TOTAL_PINS} pins in $(basename "$VERSIONS") have a" -note " retrieved upstream support policy. This check says NOTHING about the" -note " other $((TOTAL_PINS - covered)). Read a green run as 'the series named above are" -note " maintained', never as 'Kryptik ships nothing unsupported'." -note " It also does not measure patch gaps inside a supported series:" -note " that is tools/check-source-currency.sh." +note " COVERAGE FLOOR: ${covered} of ${TOTAL_PINS} pins in $(basename "$VERSIONS") have a recorded upstream policy." +note " Read a green run as 'the series above are maintained', never as 'Kryptik ships nothing unsupported'." +note " Patch gaps inside a supported series are tools/check-source-currency.sh's question." if [[ "$n_stale" -gt 0 ]]; then warn "${n_stale} policy row(s) were read more than 180 days before ${NOW}" @@ -345,20 +326,17 @@ if [[ "$hard_fail" -gt 0 ]]; then printf '\n' err "pinned series no longer supported upstream: ${n_eol}" for l in "${EOL_LINES[@]}"; do err " ${l}"; done - die "An end-of-life series is a false claim of support, so this fails in -both modes. Propose the upgrade as its own change; do not edit a pin under a -running build." + die "An end-of-life series is a false claim of support, so this fails in both modes. +Move the pin in a change of its own; do not edit a pin under a running build." fi if [[ "$soft_fail" -gt 0 ]]; then if [[ "$STRICT" -eq 1 ]]; then printf '\n' - die "${soft_fail} pinned series whose support status could NOT be -established. Unknown support is not support, so --strict fails." + die "${soft_fail} pinned series whose support status could not be established; unknown is not support" fi printf '\n' - warn "${soft_fail} pinned series whose support status could not be established." - warn "--strict would fail here. Informational mode does not." + warn "${soft_fail} pinned series whose support status could not be established; --strict would fail here" fi ok "no pinned series is known to be out of support" diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index 5de4018b..a0a04885 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -1,8 +1,6 @@ -# Where tools/check-source-currency.sh finds the newest release of a source -# whose manifest row (tools/fetch-sources.sh) declares "rule", and which of -# upstream's releases the pin may move to. Tab-separated: +# Where tools/check-source-currency.sh looks for sources whose manifest row says "rule". Tab-separated: # -# name the source, as in the manifest +# name the source, as in the manifest (tools/fetch-sources.sh) # shape listing: a page read for every match of the pattern # api: a JSON answer read for every value of a key # where the page or API URL, on the project's own host diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index d342ce16..823cdd1d 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -5,8 +5,7 @@ # ./tools/fetch-sources.sh --lock download and WRITE sources.lock # ./tools/fetch-sources.sh --list print the resolved manifest, download nothing # -# --lock records whatever downloads (trust on first use): audit it before -# committing. See docs/supply-chain.md. +# --lock trusts whatever downloads: audit sources.lock before committing it (docs/supply-chain.md). source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config @@ -21,35 +20,30 @@ esac # Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs. if [[ -n "${KRYPTIK_FETCH_MANIFEST:-}" ]]; then - [[ "${KRYPTIK_FETCH_SELFTEST:-0}" == "1" ]] || die \ -"KRYPTIK_FETCH_MANIFEST is set but KRYPTIK_FETCH_SELFTEST is not. -Refusing to fetch or lock against a substituted manifest." + [[ "${KRYPTIK_FETCH_SELFTEST:-0}" == "1" ]] \ + || die "Refusing to fetch or lock against a substituted manifest without KRYPTIK_FETCH_SELFTEST=1" warn "SELF-TEST MODE: the manifest is substituted, not the real one" fi # name|version|url|sig|new -# -# sig says how upstream vouches for the file (tools/verify-signatures.sh): -# gnu a .sig on the canonical GNU host -# kernel kernel.org's .tar.sign over the uncompressed tar -# sig asc a detached signature beside the file, with that suffix -# stem.sig a .sig beside it, named without the .tar.* suffix (less-710.sig) -# sums:NAME the signature NAME beside it, over a checksum list named NAME -# without its suffix, which must give the file's digest -# probe whichever of .sig, .asc and .sign is published; an uploaded file -# with none keeps probe, so a later release's signature is found -# sha256 the publisher's .sha256 beside it (tools/verify-provenance.sh) -# sha256.txt the same, named .sha256.txt -# tag a signed tag the archive must reproduce (tools/verify-provenance.sh) -# none nothing: a generated archive (GitHub /archive/, GitLab -# /-/archive/, sr.ht /archive/) can have nothing beside it -# new says where its newest release is found (tools/check-source-currency.sh): -# gnu the canonical GNU host's listing -# vdir the listing in the newest vN/ directory beside the file's own -# github the project's designated latest release -# listing the listing of the directory the file is in -# rule its row in tools/currency-rules.tsv -# eol nowhere: the kernel's support status is tools/check-kernel-eol.sh's +# sig: how upstream vouches for the file (tools/verify-signatures.sh) +# gnu a .sig on the canonical GNU host +# kernel kernel.org's .tar.sign over the uncompressed tar +# sig, asc a detached signature beside the file, with that suffix +# stem.sig a .sig named without the .tar.* suffix (less-710.sig) +# sums:NAME signature NAME over a checksum list (NAME minus its suffix) giving the digest +# probe whichever of .sig, .asc and .sign is published, now or in a later release +# sha256 the publisher's .sha256 beside it (tools/verify-provenance.sh) +# sha256.txt the same, named .sha256.txt +# tag a signed tag the archive must reproduce (tools/verify-provenance.sh) +# none nothing, as beside a generated GitHub, GitLab or sr.ht archive +# new: where its newest release is found (tools/check-source-currency.sh) +# gnu the canonical GNU host's listing +# vdir the listing in the newest vN/ directory beside the file's own +# github the project's designated latest release +# listing the listing of the directory the file is in +# rule its row in tools/currency-rules.tsv +# eol nowhere: tools/check-kernel-eol.sh tracks the kernel's support # follows:NAME nowhere: it moves only when NAME's pin does manifest() { if [[ -n "${KRYPTIK_FETCH_MANIFEST:-}" ]]; then @@ -180,7 +174,7 @@ fi mkdir -p "$KRYPTIK_SOURCES" -# fetch_one : try each mirror in order. +# fetch_one URL DEST: try each mirror in order. fetch_one() { local url="$1" dest="$2" local -a urls=("$url") @@ -190,9 +184,7 @@ fetch_one() { urls+=("${MIRROR_GNU_FALLBACK}/${url#"${MIRROR_GNU}/"}") fi - # Per mirror: resume, then, if a partial existed, once more from zero. A - # .part longer than the upstream file makes every resume fail (curl 36 for - # file://, 33 or HTTP 416) and would otherwise fail every mirror on every run. + # Resume, else start over: an over-long .part fails every resume (curl 33 or 36, HTTP 416). local u attempt=0 for u in "${urls[@]}"; do attempt=$((attempt + 1)) @@ -212,9 +204,7 @@ fetch_one() { return 1 } -# fetch_attempt resume|fresh -# --no-progress-meter: the bar floods logs that are not a terminal. -# --speed-limit/--speed-time: fail a stalled transfer fast, then retry. +# fetch_attempt URL DEST resume|fresh: no progress bar in logs, and a stalled transfer fails fast. fetch_attempt() { local u="$1" dest="$2" mode="$3" local -a resume=() @@ -255,9 +245,7 @@ while IFS='|' read -r name ver url _; do else log "fetching ${name} ${ver}" if ! fetch_one "$url" "$dest"; then - die "download failed: ${name} ${ver} -Tried every mirror. Re-run to resume — a partial download is kept unless -resuming from it is what failed, in which case it has been discarded." + die "download failed: ${name} ${ver} (every mirror tried). Re-run to resume." fi fetched=$((fetched + 1)) was_fetched=yes @@ -270,8 +258,7 @@ resuming from it is what failed, in which case it has been discarded." ok "${name} ${ver} ${actual:0:16}…" else if ! expected="$(lookup_hash "$file")"; then - die "no entry for ${file} in sources.lock. -Run './tools/fetch-sources.sh --lock' to generate one, then audit it." + die "no entry for ${file} in sources.lock; run './tools/fetch-sources.sh --lock', then audit it." fi if [[ "$actual" != "$expected" ]]; then err "CHECKSUM MISMATCH for ${file}" @@ -279,21 +266,15 @@ Run './tools/fetch-sources.sh --lock' to generate one, then audit it." err " actual ${actual}" # Nothing is deleted: a mismatching file is evidence. if [[ "$was_fetched" == yes ]]; then - die "This file was downloaded just now, so the DOWNLOAD is -wrong rather than the disk: a bad mirror, or a stale partial file that -poisoned a resume. Remove ${dest} and any ${dest}.part, then retry." + die "It was downloaded just now, so the download is bad: a bad mirror or a stale partial file. Remove ${dest} and any ${dest}.part, then retry." fi - die "This file was already on disk and does not match the hash -sources.lock recorded for it, so it CHANGED after it was locked. Do not -delete it yet - work out why first. See docs/supply-chain.md." + die "It was already on disk, so it CHANGED after it was locked. Do not delete it before finding out why (docs/supply-chain.md)." fi ok "${name} ${ver} verified" fi done < <(manifest) -# The GNU keyring, kept with the sources so a cache of them carries it: the -# signature gate checks GNU signatures against it, and ftp.gnu.org does not -# answer every runner every time. +# Kept in the sources cache for the signature gate, as ftp.gnu.org does not answer every runner. keyring="${KRYPTIK_SOURCES}/.keys/gnu-keyring.gpg" if [[ ! -s "$keyring" ]]; then mkdir -p "${KRYPTIK_SOURCES}/.keys" diff --git a/tools/git-hooks/pre-commit b/tools/git-hooks/pre-commit index 5251d2e4..0e104c63 100755 --- a/tools/git-hooks/pre-commit +++ b/tools/git-hooks/pre-commit @@ -6,9 +6,7 @@ set -uo pipefail fail=0 -# --- identity ----------------------------------------------------------------- -# Checked first: a wrong address hands the commit to another GitHub account. -# The checker is found relative to this file; a missing one refuses. +# --- identity, first: a wrong address gives the commit to another GitHub account --- checker="$(dirname "${BASH_SOURCE[0]}")/../check-commit-identity.sh" if [[ ! -f "$checker" ]]; then echo "pre-commit: ERROR no identity checker at ${checker}; refusing to commit unchecked" >&2 @@ -18,11 +16,7 @@ if ! bash "$checker" --pending; then exit 1 fi -# --- executable bits -------------------------------------------------------- -# NTFS has no exec bit, so a script committed from Windows records 100644 and -# a Linux clone gets "Permission denied": set the bit in the index. Hook files -# and tools/tests/*.py suites have no .sh suffix, so they are matched by name; -# git silently ignores a non-executable hook. +# --- exec bits: NTFS has none, so a script committed from Windows records 100644 --- while IFS= read -r f; do [[ -z "$f" ]] && continue mode="$(git ls-files -s -- "$f" | awk '{print $1}')" @@ -36,9 +30,7 @@ done < <(git diff --cached --name-only --diff-filter=ACM \ # build/lib/common.sh is sourced, not executed, and stays 644. -# --- CRLF ------------------------------------------------------------------- -# .gitattributes normalises line endings, but `add -f` or an editor can still -# stage CRLF, which gives "bad interpreter: bash^M". +# --- CRLF, which .gitattributes normalises but an editor or add -f can still stage --- while IFS= read -r f; do [[ -z "$f" ]] && continue if git show ":${f}" 2>/dev/null | grep -qU $'\r'; then @@ -61,8 +53,7 @@ done < <(git diff --cached --name-only --diff-filter=ACM | grep -E '\.sh$' || tr if [[ "$fail" -ne 0 ]]; then echo >&2 - echo "pre-commit: refusing the commit. Fix the above, or use --no-verify" >&2 - echo "if you genuinely intend it." >&2 + echo "pre-commit: refusing the commit. Fix the above, or commit with --no-verify if you mean it." >&2 exit 1 fi diff --git a/tools/git-hooks/pre-push b/tools/git-hooks/pre-push index 587f7e73..fc4f9d91 100755 --- a/tools/git-hooks/pre-push +++ b/tools/git-hooks/pre-push @@ -1,11 +1,9 @@ #!/usr/bin/env bash -# Kryptik pre-push hook: refuse to push a commit under any other identity, even -# one made past pre-commit (another checkout, a rebase, --no-verify). +# Kryptik pre-push hook: refuse commits under another identity, even ones that bypassed pre-commit. # Install with ./tools/install-git-hooks.sh. set -uo pipefail -# Found relative to this file, as in pre-commit; missing means refuse. checker="$(dirname "${BASH_SOURCE[0]}")/../check-commit-identity.sh" if [[ ! -f "$checker" ]]; then echo "pre-push: ERROR no identity checker at ${checker}; refusing to push unchecked" >&2 @@ -17,8 +15,7 @@ bad=0 while read -r _local_ref local_sha _remote_ref remote_sha; do [[ "$local_sha" == "$zero" ]] && continue # deleting a ref - # A new ref, or a force-push whose old tip is unknown here: check the - # whole history behind the new tip. + # A new ref, or a force-push over a tip unknown here: check all history behind the new tip. if [[ "$remote_sha" == "$zero" ]] || ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then range="$local_sha" else diff --git a/tools/install-git-hooks.sh b/tools/install-git-hooks.sh index d22caeff..9f423b26 100755 --- a/tools/install-git-hooks.sh +++ b/tools/install-git-hooks.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash -# Install the git hooks: point core.hooksPath at tools/git-hooks, so they stay -# under review, and check that git will run each one. +# Point core.hooksPath at tools/git-hooks, so the hooks stay under review, and check git runs each. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" @@ -12,8 +11,7 @@ cd "$KRYPTIK_ROOT" || die "cannot enter ${KRYPTIK_ROOT}" git config core.hooksPath "$HOOKS_DIR" chmod +x "${HOOKS_DIR}"/* 2>/dev/null || true -# Git silently ignores a non-executable hook. The chmod above fixes only the -# working tree; a new checkout takes the index mode, so check that too. +# Git ignores a non-executable hook, and a new checkout takes the index mode: check both. problems=0 hooks=0 for h in "${HOOKS_DIR}"/*; do @@ -22,8 +20,7 @@ for h in "${HOOKS_DIR}"/*; do if [[ ! -x "$h" ]]; then err "${h} is not executable, so git will IGNORE it" - err " the filesystem may be unable to express the executable bit" - err " (NTFS cannot); a hook that cannot be marked executable cannot run" + err " the filesystem may have no executable bit (NTFS has none)" problems=$((problems + 1)) continue fi @@ -33,15 +30,13 @@ for h in "${HOOKS_DIR}"/*; do warn "${h} is not tracked by git, so it will not survive a fresh clone" elif [[ "$mode" == "100644" ]]; then warn "${h} is recorded ${mode} in the index" - warn " a fresh clone would get a hook git ignores, exactly as this" - warn " repository did. Correcting the index mode now." + warn " a fresh clone would get a hook git ignores; correcting the index mode" git update-index --chmod=+x -- "$h" ok " ${h} is now 100755 in the index - COMMIT THAT CHANGE" fi - # Ask git itself. A hook that runs and fails is fine: nothing is staged. name="$(basename "$h")" - # stdin from /dev/null, or pre-push waits for a ref list. + # Ask git itself (a hook that runs and fails is fine); &1 < /dev/null | grep -q 'cannot find a hook'; then err "git cannot find a hook named ${name} even after the above" problems=$((problems + 1)) @@ -49,13 +44,9 @@ for h in "${HOOKS_DIR}"/*; do done [[ "$hooks" -gt 0 ]] || die "no hook files in ${HOOKS_DIR}" -[[ "$problems" -eq 0 ]] || die "${problems} hook(s) git will not run. -Nothing here is installed in any useful sense until that is fixed, and a -pre-commit check that does not run is worse than none: it is a check that -everybody believes is happening." +[[ "$problems" -eq 0 ]] || die "${problems} hook(s) git will not run" ok "${hooks} hook(s) installed and confirmed runnable (core.hooksPath = $(git config core.hooksPath))" echo -dim "The pre-commit hook fixes executable bits automatically and refuses" -dim "commits containing CRLF or shell syntax errors. Both of those have" -dim "broken a clone of this repository already." +dim "pre-commit checks the identity, sets exec bits and refuses CRLF and shell syntax errors." +dim "pre-push refuses commits under any other identity." diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 29d023b8..4a7e2dcb 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -1,14 +1,11 @@ -# Reviews of pins that are behind their upstream. Read by tools/check-pin-reviews.sh. +# Reviews of pins that are behind their upstream, read by tools/check-pin-reviews.sh. # # package pinned reviewed_up_to fine|held date what was read, and why # -# fine: nothing released after the pin, up to reviewed_up_to, is a security fix -# that reaches Kryptik. held: something is, and the note says why the pin stays. -# A pin that has caught up has no row; a row for it fails, so this file shrinks. - -# Reviewed 2026-09-19 from upstream NEWS and changelogs, project security -# pages, the Debian security tracker and NVD records. "Survey" below is -# check-source-currency.sh's newest-upstream column on that day. +# reviewed_up_to: upstream's newest in the survey (check-source-currency.sh --tsv) on that date. +# fine: nothing released after the pin, up to reviewed_up_to, is a security fix that reaches Kryptik. +# held: something is, and the note says why the pin stays. A pin that has caught up has no row. +# Read from upstream NEWS and changelogs, security pages, the Debian security tracker and NVD. # --- toolchain ----------------------------------------------------------------- binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debian tracker: every CVE since 2.43 is a crafted-input bug in tools that are only fed the project's own objects, and upstream's SECURITY.txt excludes them; no wrong-code or hardening fix found. 2.44 drops gold, which the recipe enables. diff --git a/tools/prune-sources.sh b/tools/prune-sources.sh index 272ff1c6..743d0417 100755 --- a/tools/prune-sources.sh +++ b/tools/prune-sources.sh @@ -1,11 +1,10 @@ #!/usr/bin/env bash -# Remove from the sources directory every file the manifest no longer names. +# Remove from the sources directory every file the manifest does not name. # # ./tools/prune-sources.sh remove them # ./tools/prune-sources.sh --dry-run say what would go # -# Keeps CI's restored source cache from growing by a tarball per version bump. -# Only regular files directly under KRYPTIK_SOURCES are touched. +# Keeps CI's source cache from growing by a tarball per version bump; subdirectories are left alone. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config diff --git a/tools/resolve-kernel-config.sh b/tools/resolve-kernel-config.sh index 312d28b9..4c733849 100755 --- a/tools/resolve-kernel-config.sh +++ b/tools/resolve-kernel-config.sh @@ -1,15 +1,9 @@ #!/usr/bin/env bash -# Resolve the kernel config on the host as stage 05 does in the chroot, and -# check that every fragment line survived. +# Resolve the kernel config on the host as stage 05 does, and check that every fragment line survived. # -# ./tools/resolve-kernel-config.sh [OUT] +# ./tools/resolve-kernel-config.sh [OUT] default OUT: ${KRYPTIK_WORK}/kconfig-tree/kryptik.config # -# OUT where the resolved .config is written; -# default ${KRYPTIK_WORK}/kconfig-tree/kryptik.config -# -# validate-kernel-config.sh shows each symbol exists; this shows it survives -# resolution, which drops unmet dependencies as silently as typos. The host's -# gcc stands in for the chroot's, so CC_HAS_* and GCC plugin options can differ. +# The host's gcc stands in for the chroot's, so CC_HAS_* and GCC plugin options can differ. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/kconfig-check.sh" @@ -32,7 +26,7 @@ done # Unpacked and patched once per linux-hardened version, which the stamp names. stamp="${TREE_DIR}/.patched-${V_LINUX_HARDENED}" if [[ ! -f "$stamp" ]]; then - log "unpacking linux-${V_LINUX} (this is the whole tree, kconfig needs its Makefile and scripts)" + log "unpacking linux-${V_LINUX} (kconfig needs the whole tree's Makefile and scripts)" rm -rf "$TREE_DIR"; mkdir -p "$TREE_DIR" tar -xf "$TARBALL" -C "$TREE_DIR" [[ -d "$KSRC" ]] || die "expected ${KSRC} after unpacking" @@ -55,8 +49,8 @@ plugin_dir="$(gcc -print-file-name=plugin 2>/dev/null || true)" if [[ -e "${plugin_dir}/include/plugin-version.h" ]]; then dim " host gcc $(gcc -dumpversion) has plugin headers: GCC plugin options resolve as in the chroot" else - warn "host gcc $(gcc -dumpversion) has no plugin headers (${plugin_dir}/include); every GCC_PLUGINS-dependent" - warn "option will read as DROPPED here although the chroot's compiler has them. Install gcc-$(gcc -dumpversion | cut -d. -f1)-plugin-dev." + warn "host gcc $(gcc -dumpversion) has no plugin headers (${plugin_dir}/include): GCC_PLUGINS options read as DROPPED here" + warn "the chroot's compiler has them; install gcc-$(gcc -dumpversion | cut -d. -f1)-plugin-dev" fi dim " =y: $(grep -c '=y$' .config) =m: $(grep -c '=m$' .config)" @@ -74,5 +68,5 @@ if kconfig_fragment_check "$OUT" "${FRAGMENTS[@]}"; then ok "every fragment line survived resolution" else echo - die "fragment lines were not honoured. Each is a mitigation or a driver the built kernel would not have while the fragment says it does." + die "fragment lines were not honoured: the built kernel would lack what the fragment asks for" fi diff --git a/tools/run-tests.sh b/tools/run-tests.sh index b58f1df2..6b108e4f 100755 --- a/tools/run-tests.sh +++ b/tools/run-tests.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -# Run every suite that needs no root and no build chroot, and name the ones -# that did not run. Suites run directly, not through make, which would turn -# their exit 77 (a missing dependency) into exit 2. +# Run every suite that needs no root and no build chroot, and name the ones that did not run. # # tools/run-tests.sh exit 1 if any suite failed # tools/run-tests.sh --strict also exit 1 if any suite did not run @@ -13,9 +11,7 @@ cd "$ROOT" || exit 1 STRICT=0 [[ "${1:-}" == "--strict" ]] && STRICT=1 -# Every tools/tests/* file is a suite, except these: the first two chroot into -# the built system (acceptance items), the last two run with the compartment -# suites below. +# Not here: the first two need the built system, the last two run with the compartment suites. ELSEWHERE=" libc-unwind.sh userspace-smoke.sh desktop-identity.sh compositor.sh " SUITES=() for t in tools/tests/*.sh tools/tests/*.py; do @@ -45,7 +41,7 @@ run_suite() { # run_suite