From aee673f15de3c28bc38c234491d572924d2439fb Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:43:14 +0700 Subject: [PATCH] rootfs: strace in the image, built from source and pinned by checksum The image had no way to say which system calls a program made. A function graph tape says what the kernel did once it was inside a call; the arguments, the flags and the errno on the way back are not in it and no amount of reading one recovers them. strace is built rather than downloaded because nobody publishes a static i686 one. It costs about a megabyte compressed, which more than doubles the initramfs, and about thirty seven minutes to build on an arm laptop, which is qemu translating x86 one instruction at a time. The result is cached beside busybox so it happens once, and two builds from the same tarball in two fresh containers came out with the same sha256. build.sh had three near identical pin readers and was about to get a fourth, so there is one now that takes the section as an argument, and one fetch helper that will not hand back a file whose checksum does not match. Three tests check that build.sh only asks for keys the pin has, that the reader finds both call spellings, and that everything coming off the network is pinned. A smoke check runs strace and looks for what it printed, so a kernel that loses ptrace fails on its own line while everything else still passes. --- LAYOUT.md | 4 +- kxbox/README.md | 2 +- kxbox/kernel/RESULTS.md | 15 +++++- kxbox/rootfs/README.md | 44 +++++++++++++++-- kxbox/rootfs/build.sh | 105 ++++++++++++++++++++++++++-------------- kxbox/rootfs/pin.toml | 67 ++++++++++++++++++++++--- kxbox/web/README.md | 4 +- kxbox/web/checks.js | 3 ++ tests/test_kxbox.py | 58 ++++++++++++++++++++++ 9 files changed, 247 insertions(+), 55 deletions(-) diff --git a/LAYOUT.md b/LAYOUT.md index 5765013..c835715 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -23,7 +23,7 @@ linux-kernel-internals/ │ ├── web/ # the browser half: the channel, the shell protocol, the page │ ├── web/vendor/v86/ # pinned upstream by commit and sha256, BSD-2-Clause, not committed │ ├── kernel/ # pin.toml, config fragments, build.sh, results -│ └── rootfs/ # a pinned busybox and a thirty line init +│ └── rootfs/ # busybox, strace, three small programs, a thirty line init ├── kxdiff/ # comparing two traces: policy.py says what, levels.py says how strictly ├── kxshapes/ # the nine shapes, as data, drawn by both renderers below ├── kxwidgets/ # shapes.py draws the nine, the rest compose them into widgets @@ -167,7 +167,7 @@ There is no profile called kasan and the milestone asked for one. `arch/x86/Kcon `kxbox/web/headless.js` boots the same kernel under node instead of on a page, which is what makes a boot something CI and a bisect can do rather than something a person has to click through. It shares `serialFor` and `waitForBoot` with `page.js` and everything below them, so the two ways in can disagree about the machine but not about us. -`kxbox/rootfs/` is the userland the kernel boots into: a pinned busybox and a thirty line init that mounts four filesystems and prints one marker. It stops there on purpose. An init that turns a tracer on behind the reader's back makes every lesson about that tracer a lie. +`kxbox/rootfs/` is the userland the kernel boots into: a pinned busybox, a pinned strace built from source because nobody publishes a static 32-bit one, three small programs that each put one system call inside the tracer window so a trace of one has one thing in it, and a thirty line init that mounts four filesystems and prints one marker. The init stops there on purpose. An init that turns a tracer on behind the reader's back makes every lesson about that tracer a lie. `kxbox/web/serve.py` is there because a blocking worker needs a `SharedArrayBuffer`, a useful `SharedArrayBuffer` needs the page to be cross origin isolated, and that needs two response headers `python3 -m http.server` does not send. Getting it wrong does not look like a header problem, it looks like the emulator hanging, so the headers have a test. diff --git a/kxbox/README.md b/kxbox/README.md index 6b0e23c..859487f 100644 --- a/kxbox/README.md +++ b/kxbox/README.md @@ -74,7 +74,7 @@ These three names are separate from the six build profiles in `kernel/pin.toml`. `kernel/` is the pin, the config fragments and the build script. `pin.toml` says which kernel, from where, with which checksum. -`rootfs/` is the initramfs: a pinned busybox, an init script that mounts the filesystems the lessons need and prints the ready marker, and a build script that makes the cpio. `web/vendor.toml` pins v86 and `tools/vendor.py` fetches it against those checksums. +`rootfs/` is the initramfs: a pinned busybox, a pinned strace built from source, three small programs that each do one thing so a trace of one has one thing in it, an init script that mounts the filesystems the lessons need and prints the ready marker, and a build script that makes the cpio. `web/vendor.toml` pins v86 and `tools/vendor.py` fetches it against those checksums. ## Booting one diff --git a/kxbox/kernel/RESULTS.md b/kxbox/kernel/RESULTS.md index 1801b46..d0d812c 100644 --- a/kxbox/kernel/RESULTS.md +++ b/kxbox/kernel/RESULTS.md @@ -90,7 +90,20 @@ A boot that reaches a shell and cannot trace is a failure for this project even | `B-btf-external` | yes | yes | yes | yes | yes | no, and that is the point | | `C-longterm` | not measured | not measured | not measured | not measured | not measured | not measured | -The `A-full` row is what `headless.js smoke` checks, nine checks, each one named after a lesson that stops working without it. Alongside those: 50787 symbols in `/proc/kallsyms`, 16755 functions in `available_filter_functions`, and a real filtered `function_graph` tape came back through the four call protocol in 3.2 seconds. +The `A-full` row is what `headless.js smoke` checks, eleven checks, each one named after a lesson that stops working without it. Alongside those: 50787 symbols in `/proc/kallsyms`, 16755 functions in `available_filter_functions`, and a real filtered `function_graph` tape came back through the four call protocol in 3.2 seconds. + +## What the rootfs costs + +The kernel is one of two things a reader downloads and the initramfs is the other, so its size belongs in the same table as the image sizes above. + +| What is in it | Compressed | Measured | +| --- | --- | --- | +| busybox, three small programs, `abba.ko` | 712410 bytes (696 KiB) | 2026-09-02 | +| the same plus strace 7.2 | 1737376 bytes (1.66 MiB) | 2026-09-06 | + +strace more than doubles the initramfs and adds a quarter to what a reader fetches in total, which goes from 3.93 MiB to 4.91 MiB alongside `A-full`. That is the same kind of trade `B-btf-external` was built to price and it has the same answer: everything measured here is served from `127.0.0.1`, so a megabyte costs nothing on this machine and this cannot settle it. What it does settle is the other half. A boot with the larger image still reaches a shell under node in 6.3 seconds and all eleven checks pass, so the extra megabyte does not slow the boot down in any way this can detect. Whether it is worth a quarter of the download is a question for a real network. + +Both numbers move by a few bytes between runs, because the cpio carries the modification time of every file in it and gzip compresses those times along with everything else. The strace binary itself does not move at all: two builds from the same tarball in two fresh containers came out with the same sha256, which is the part worth being able to say. The `no` in the `B-btf-external` row is the profile working. `/sys/kernel/btf` is not there at all, tracing and kallsyms are untouched, and the type information a blueprint needs has to come down as a second file. What that costs a reader is one more download and one more thing that can be missing, which is exactly the trade this profile exists to price. diff --git a/kxbox/rootfs/README.md b/kxbox/rootfs/README.md index 1ab3734..3d072d2 100644 --- a/kxbox/rootfs/README.md +++ b/kxbox/rootfs/README.md @@ -2,7 +2,7 @@ The kernel is the subject of this book. The rootfs is the smallest thing that lets you talk to it. -One statically linked busybox, three nine kilobyte programs, and one init script. That is the whole image, and it comes to 649 KiB compressed, next to a 3.25 MiB kernel. +One statically linked busybox, one strace, three nine kilobyte programs, and one init script. That is the whole image, and it comes to 1.66 MiB compressed, next to a 3.25 MiB kernel. ## Building it @@ -10,12 +10,44 @@ One statically linked busybox, three nine kilobyte programs, and one init script sh kxbox/rootfs/build.sh ``` -No root. It downloads busybox, checks it against the sha256 in `pin.toml`, asks the binary which applets it has and stops if one the bridge needs is missing, compiles the three programs, then makes a cpio archive with `cpio -R 0:0` so every file is owned by root without anybody having to be root to say so. +No root. It downloads busybox, checks it against the sha256 in `pin.toml`, asks the binary which applets it has and stops if one the bridge needs is missing, compiles the three programs, builds strace, then makes a cpio archive with `cpio -R 0:0` so every file is owned by root without anybody having to be root to say so. -The compiled programs need the container, because they are 32-bit x86 and the machine building them usually is not. That is the same container the kernel is built in, so it adds no toolchain that was not already needed. A machine with no docker gets an image without it and a warning saying what that costs. +Everything compiled needs the container, because it is all 32-bit x86 and the machine building it usually is not. That is the same container the kernel is built in, so it adds no toolchain that was not already needed. A machine with no docker gets an image without any of it and a warning saying what that costs. The output is `build/initrd.gz` and it is not committed. +## strace + +The function graph tracer says what the kernel did once it was inside a call, and how long each frame took. It does not say what the program asked for. The arguments, the file descriptors, the flags and the errno on the way back are not in a tape and no amount of reading one recovers them, because the tracer records entry and exit and those values live in registers it never samples. + +Those are the first questions anybody has, and until now the box could not answer them. Here is `writebyte`, which the section below describes in words: + +``` +execve("/bin/writebyte", ["/bin/writebyte", "/tmp/probe"], 0xbffaf98c /* 6 vars */) = 0 +open("/tmp/.writebyte-warmup", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 3 +write(3, "x", 1) = 1 +close(3) = 0 +unlink("/tmp/.writebyte-warmup") = 0 +open("/tmp/one-byte", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 3 +open("/sys/kernel/tracing/tracing_on", O_WRONLY) = 4 +write(4, "1\n", 2) = 2 +write(3, "x", 1) = 1 +write(4, "0\n", 2) = 2 +close(4) = 0 +close(3) = 0 +write(1, "wrote 1 byte to /tmp/one-byte\n", 30) = 30 +exit(0) = ? ++++ exited with 0 +++ +``` + +Everything the section below claims is on that page. The warmup runs the whole sequence once and throws it away. The file is opened before the window rather than inside it. Between the two writes to `tracing_on` there is one `write`, which is the claim all three programs are built on and which nothing was checking until this binary went in. + +It is built from the release tarball rather than downloaded, because nobody publishes a static i686 strace. The build is the expensive thing here: about thirty seven minutes on an arm laptop, where almost all of it is qemu translating x86 one instruction at a time, and a couple of minutes on an x86 machine. The result is cached beside busybox and reused, so it happens once. + +It costs about a megabyte compressed, which more than doubles the image. Most of that is a static glibc plus the tables that turn a syscall number and six longs into a line somebody can read, and those tables are the entire feature. + +Three things it will not do here. `-k` needs libunwind and there is none, so `strace -V` reports `Optional features enabled: (none)`. The `seconds` column of `strace -c` is emulated time on Tier 0 and means nothing, the same as every other duration in the box. And attaching to a process stops it twice per system call, so a tape taken while strace is attached is a tape of ptrace doing its job, which is a real trace of something nobody asked about. + ## Modules The image also carries any `.ko` it finds beside a lesson, which today means `abba.ko` from `lessons/C09/assets`, put in at `/lib/modules` where `insmod` can reach it. @@ -52,11 +84,13 @@ Both writes are in one tracer window rather than in two. Two captures taken a se `corpora/traces/tier0/two-writes.txt` is what it produces. -## Why an upstream binary +## Why busybox is downloaded and strace is built The busybox here is somebody else's build, downloaded from busybox.net. Building it from source would need a second cross toolchain and a second container, and would make the fast half of Tier 0 as slow as the kernel half. -The checksum is what makes that safe rather than a shrug. `pin.toml` records the version, the URL, the size and the sha256, and the build refuses a binary that does not match. If this ever needs to become a real build, it happens in this directory and nothing above it changes. +strace goes the other way for a duller reason than any argument: there is no static i686 binary to download. So it is built, and the thirty seven minutes that costs is the price of the only option there was. + +The checksum is what makes both safe rather than a shrug. `pin.toml` records the version, the URL, the size and the sha256 of each, and the build refuses anything that does not match. For strace the checksum is on the source tarball, which is the thing that came off the network, and `built_bytes` beside it records what the compiler then made of it, so a build that starts producing something a long way from that is a build somebody should look at. ## What init does, and what it deliberately does not diff --git a/kxbox/rootfs/build.sh b/kxbox/rootfs/build.sh index ea79786..0b45bf7 100755 --- a/kxbox/rootfs/build.sh +++ b/kxbox/rootfs/build.sh @@ -3,10 +3,11 @@ # # ./kxbox/rootfs/build.sh # -# The result is kxbox/rootfs/build/initrd.gz, which is one busybox, one init script and four empty -# directories. It is about half a megabyte and it is not committed, the same as the kernel. +# The result is kxbox/rootfs/build/initrd.gz, which is one busybox, one strace, three small +# programs, one init script and four empty directories. It is not committed, the same as the +# kernel. # -# This one needs no container and no root. Everything in it is a regular file, a directory or a +# This one needs no root. Everything in it is a regular file, a directory or a # symlink, and the device nodes the kernel needs are made by devtmpfs before init runs, which is # what CONFIG_DEVTMPFS_MOUNT in the teaching fragment is for. A rootfs that needed mknod would need # root, and asking a reader for root to build a teaching image is a bad trade. @@ -18,54 +19,50 @@ OUT="$HERE/build" STAGE="$OUT/root" PIN="$HERE/pin.toml" +# One reader for the whole pin file, called as `read_pin
`. There were three of +# these, one per section, identical apart from the name in the middle, and a fourth was about to be +# written for strace. A list comes back space separated because that is what a shell loop wants. read_pin() { - python3 - "$PIN" "$1" <<'PY' + python3 - "$PIN" "$1" "$2" <<'PY' import sys, tomllib -pin = tomllib.load(open(sys.argv[1], "rb"))["busybox"] -value = pin[sys.argv[2]] +value = tomllib.load(open(sys.argv[1], "rb"))[sys.argv[2]][sys.argv[3]] print(" ".join(value) if isinstance(value, list) else value) PY } -read_programs() { - python3 - "$PIN" "$1" <<'PY' -import sys, tomllib -pin = tomllib.load(open(sys.argv[1], "rb"))["programs"] -value = pin[sys.argv[2]] -print(" ".join(value) if isinstance(value, list) else value) +# Fetch a file once and check it against the sha256 the pin records. Both things this image +# downloads come through here, and neither is used before the checksum has matched. +fetch() { + url=$1 + into=$2 + want=$3 + if [ ! -f "$into" ]; then + echo "fetching $url" + curl -fL --progress-bar -o "$into.part" "$url" + mv "$into.part" "$into" + fi + python3 - "$into" "$want" <<'PY' +import hashlib, sys +digest = hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest() +if digest != sys.argv[2]: + sys.exit(f"checksum mismatch, refusing to build a rootfs\n wanted {sys.argv[2]}\n got {digest}") +print(f"checksum ok: {sys.argv[1].rsplit('/', 1)[-1]}") PY } -read_modules() { - python3 - "$PIN" "$1" <<'PY' -import sys, tomllib -pin = tomllib.load(open(sys.argv[1], "rb"))["modules"] -value = pin[sys.argv[2]] -print(" ".join(value) if isinstance(value, list) else value) -PY -} +read_programs() { read_pin programs "$1"; } +read_modules() { read_pin modules "$1"; } +read_strace() { read_pin strace "$1"; } -VERSION=$(read_pin version) -URL=$(read_pin url) -SHA=$(read_pin sha256) -APPLETS=$(read_pin required_applets) +VERSION=$(read_pin busybox version) +URL=$(read_pin busybox url) +SHA=$(read_pin busybox sha256) +APPLETS=$(read_pin busybox required_applets) BUSYBOX="$OUT/busybox-$VERSION" mkdir -p "$OUT" -if [ ! -f "$BUSYBOX" ]; then - echo "fetching $URL" - curl -fL --progress-bar -o "$BUSYBOX.part" "$URL" - mv "$BUSYBOX.part" "$BUSYBOX" -fi - -python3 - "$BUSYBOX" "$SHA" <<'PY' -import hashlib, sys -digest = hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest() -if digest != sys.argv[2]: - sys.exit(f"checksum mismatch, refusing to build a rootfs\n wanted {sys.argv[2]}\n got {digest}") -print("checksum ok") -PY +fetch "$URL" "$BUSYBOX" "$SHA" # What the binary says it can do, checked against what the bridge needs. The list of applets is in # the binary itself, so this is asking it rather than trusting the version number. @@ -116,6 +113,40 @@ else echo " without them: a page fault trace is thirty faults and a write trace is nine writes" fi +# strace, built from the release tarball rather than downloaded as a binary, because nobody +# publishes a static i686 one and this project is not going to be the first. +# +# The result is cached beside busybox and reused, which matters more here than anywhere else in +# this file. The build is a configure and a make, it took thirty seven minutes the first time it +# ran on an arm laptop, and almost all of that is qemu translating x86 instructions one at a time. +# On an x86 machine it is a couple of minutes. Either way it happens once. +STRACE_VERSION=$(read_strace version) +STRACE="$OUT/strace-$STRACE_VERSION-i686" +if command -v docker >/dev/null 2>&1; then + if [ ! -f "$STRACE" ]; then + TARBALL="$OUT/strace-$STRACE_VERSION.tar.xz" + fetch "$(read_strace url)" "$TARBALL" "$(read_strace sha256)" + echo "building strace $STRACE_VERSION, which takes a while and then never happens again" + docker run --rm -v "$OUT:/out" "$(read_strace image)" sh -eu -c " + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq >/dev/null + apt-get install -y -qq --no-install-recommends $(read_strace packages) >/dev/null + cd /tmp && tar xf /out/strace-$STRACE_VERSION.tar.xz && cd strace-$STRACE_VERSION + ./configure $(read_strace configure) LDFLAGS=-static >/tmp/configure.log 2>&1 \ + || { tail -30 /tmp/configure.log; exit 1; } + make -j4 >/tmp/make.log 2>&1 || { tail -40 /tmp/make.log; exit 1; } + $(read_strace strip) src/strace + cp src/strace /out/strace-$STRACE_VERSION-i686 + " + fi + cp "$STRACE" "$STAGE/bin/strace" + chmod 755 "$STAGE/bin/strace" + echo "strace: $STRACE_VERSION, $(wc -c < "$STRACE") bytes" +else + echo "no docker, so no strace in this image" + echo " without it: nothing shows which system calls a program made, only what they did inside" +fi + # Any module somebody has built, copied in as it is. Nothing here compiles one, because a module # needs the kernel tree and the kernel tree needs the container and the volume, which is what # `kxbox/kernel/module.sh` is for. This just carries whatever is already on disk. diff --git a/kxbox/rootfs/pin.toml b/kxbox/rootfs/pin.toml index b038d66..a4ab7be 100644 --- a/kxbox/rootfs/pin.toml +++ b/kxbox/rootfs/pin.toml @@ -2,14 +2,16 @@ schema = 1 # The userland that comes up inside Tier 0, pinned the same way the kernel is. # -# One statically linked busybox and nothing else. Static because a dynamic binary drags a C library -# into the image and the image is the thing a browser pays for twice, and because a rootfs with one -# file in it has one thing that can be wrong with it. +# Everything in the image is statically linked. A dynamic binary drags a C library in beside it, +# and the image is the thing a browser pays for twice. # -# This is an upstream prebuilt binary rather than one this project compiled. That is a deliberate -# trade and it is worth saying out loud: it saves a second cross toolchain and a second build, and -# it means the shell in the box is somebody else's build. The checksum below is what pins it. If -# this ever needs to become a real build, it lives here and nothing above it changes. +# The busybox below is an upstream prebuilt binary rather than one this project compiled. That is a +# deliberate trade and it is worth saying out loud: it saves a second cross toolchain and a second +# build, and it means the shell in the box is somebody else's build. The checksum is what pins it. +# If this ever needs to become a real build, it lives here and nothing above it changes. +# +# strace further down goes the other way and is built from source, for the plain reason that nobody +# publishes a static i686 one. The three programs after it are ours. [busybox] version = "1.35.0" @@ -61,6 +63,57 @@ packages = ["gcc-i686-linux-gnu"] # reserves for itself. flags = ["-static", "-nostdlib", "-fno-pic", "-fno-stack-protector", "-Os", "-Wall", "-Wextra", "-Werror"] +# strace, which answers the one question the function graph tracer cannot answer at all. +# +# A tape says what the kernel did inside a call and how long each frame took. It does not say what +# the program asked for. The arguments, the file descriptors, the flags and the errno on the way +# back are not in a function graph trace and no amount of reading one recovers them, because the +# tracer records entry and exit and the values live in registers it does not sample. Those are the +# first questions a beginner has, they are the whole of the Tourist tier, and until now the box had +# no way to answer them. +# +# The two together are the point rather than either alone. `strace` on `writebyte` shows one +# `write` in the tracer window, which is the claim the three programs beside this file are built on +# and which nothing was checking. The tape shows what that one write turned into. +# +# Built from the release tarball, not downloaded as a binary, because nobody publishes a static +# i686 strace. That is the opposite trade from busybox above and it is made for a plain reason: +# there was nothing to download. +# +# It costs about a megabyte compressed, which more than doubles the image. Most of that is a static +# glibc plus the tables that turn a syscall number and six longs into a line somebody can read, and +# those tables are the entire feature. Building against musl would cut it and would mean a second +# cross toolchain, which is the trade `kernel/README.md` already refused once. +# +# `--enable-mpers=no` because mpers is for a 64 bit strace decoding a 32 bit process. This strace +# is 32 bit and there is one personality here, so the machinery has nothing to do. It is also the +# part of the build that needs the most from the toolchain, so turning it off removes the thing +# most likely to break on somebody else's machine. +# +# `configure` needs a native compiler as well as the cross one. It builds and runs a few small +# programs on the build host on its way to deciding things about the target, which is why `gcc` and +# `libc6-dev` are in the list next to their cross versions. +[strace] +version = "7.2" +url = "https://github.com/strace/strace/releases/download/v7.2/strace-7.2.tar.xz" +sha256 = "4bde6246926890dcee824f6e6ac42a06752f47d77e5097d86e3c0d6d4b709fe5" +size_bytes = 2810844 +recorded = "2026-09-06" +image = "debian:trixie-slim" +packages = [ + "gcc", + "gcc-i686-linux-gnu", + "libc6-dev", + "libc6-dev-i386-cross", + "make", + "xz-utils", +] +configure = ["--host=i686-linux-gnu", "--enable-mpers=no"] +strip = "i686-linux-gnu-strip" +# What the built binary comes to, stripped, so a build that suddenly produces something a long way +# from this has changed in a way somebody should look at. +built_bytes = 2446716 + # Kernel modules the image carries, if somebody has built them. # # These are optional in a way the programs above are not. A module is compiled against one profile diff --git a/kxbox/web/README.md b/kxbox/web/README.md index dbfd9cc..9493db3 100644 --- a/kxbox/web/README.md +++ b/kxbox/web/README.md @@ -40,13 +40,13 @@ A request goes to the page as a message, which is queued before the worker goes ## The state of it -It boots. `node kxbox/web/headless.js smoke` starts the pinned kernel, waits for the ready marker, and runs nine checks, each one named after something a lesson stops working without. +It boots. `node kxbox/web/headless.js smoke` starts the pinned kernel, waits for the ready marker, and runs eleven checks, each one named after something a lesson stops working without. Running it found two bugs that the tests had been passing over. A write to any tracefs file did nothing and reported success, and every read came back with one extra newline. Both bugs were in code with tests, and in both cases the test double was the thing that was wrong: it had been written to match the protocol as designed rather than what a busybox shell on a serial line actually does. The doubles now match the real guest, and there are tests that fail if they drift apart again. Everything that can be checked without an emulator still is, because that is what runs in CI. `just web` runs it. Forty one tests, and the useful ones are the parsing of a serial stream that contains the prompt and the echo of the command, a write arriving in pieces and ending up as one file, two commands not interleaving on the one shell, and a blocking call across two real threads with the answer deliberately late. -It also runs in a browser now, which is what M0 was actually asking. `just web-measure` boots the pinned kernel in Chrome on a throwaway profile, runs the same ten checks, brings Pyodide up in the worker, runs every recipe against its recording, and takes one filtered trace all the way through the bridge and back. A shell in about two and a half seconds on an idle laptop, ten checks passing, three of three recipes agreeing in about seventeen seconds, and forty one frames of a real tape drawn by the same widget a notebook would use. The numbers and the surprises are in `../kernel/RESULTS.md`. +It also runs in a browser now, which is what M0 was actually asking. `just web-measure` boots the pinned kernel in Chrome on a throwaway profile, runs the same checks, brings Pyodide up in the worker, runs every recipe against its recording, and takes one filtered trace all the way through the bridge and back. A shell in about two and a half seconds on an idle laptop, every check passing, three of three recipes agreeing in about seventeen seconds, and forty one frames of a real tape drawn by the same widget a notebook would use. The numbers and the surprises are in `../kernel/RESULTS.md`. Two surprises are worth repeating here. A visible window is about three times slower than a headless one, and almost all of that is sensitivity to what else the machine is doing rather than a fixed cost. Headless boots in 2.2 seconds whether the laptop is idle or has several compiler jobs on it. Visible goes from 2.6 seconds to between 6.4 and 9.1. Every node number this project quoted before is therefore optimistic about what a reader waits for, and none of them was wrong about whether it works. diff --git a/kxbox/web/checks.js b/kxbox/web/checks.js index 7386db3..8fc75fc 100644 --- a/kxbox/web/checks.js +++ b/kxbox/web/checks.js @@ -18,6 +18,9 @@ export const CHECKS = [ { name: "readback", what: "the write above actually landing", run: (box) => box.read("/tmp/probe") }, { name: "modules", what: "every part that ends in a change", run: (box) => box.sh("test -d /sys/module") }, { name: "touchpage", what: "a page fault trace with one fault in it instead of thirty", run: (box) => box.sh("/bin/touchpage") }, + // ptrace rather than the tracer, so this fails on its own if the kernel loses PTRACE_ATTACH or + // the binary was built against the wrong libc, and it fails while everything above still passes. + { name: "strace", what: "which system calls a program made, and what they returned", run: (box) => box.sh("strace -o /tmp/s.txt /bin/busybox true && grep -c execve /tmp/s.txt") }, ]; // A reply is a string from `read` and an object from `sh`, and nothing above cares which. diff --git a/tests/test_kxbox.py b/tests/test_kxbox.py index 35a60fa..1d86e4f 100644 --- a/tests/test_kxbox.py +++ b/tests/test_kxbox.py @@ -703,3 +703,61 @@ def test_the_browser_demo_asks_for_what_the_recipe_asks_for(): assert f"owns_window={one.owns_window}" in program, "the demo should use the recipe's window" for name in one.functions: assert f'"{name}"' in program, f"the demo should ask for `{name}` like the recipe does" + + +# The rootfs recipe. +# +# Everything the image is made of is a row in `kxbox/rootfs/pin.toml`, and `build.sh` reads that +# file through one helper. A key spelled wrong is a `KeyError` from a heredoc partway through a +# build, which for strace is thirty seven minutes in on the machine most likely to be running this. + +ROOTFS_PIN = ROOT / "kxbox" / "rootfs" / "pin.toml" +BUILD_SH = ROOT / "kxbox" / "rootfs" / "build.sh" + + +def asked_for(script: str) -> set[tuple[str, str]]: + """Every (section, key) `build.sh` reads out of the pin, including through a named helper. + + Two spellings reach the pin. `read_pin busybox version` names its section, and a one line + helper like `read_strace() { read_pin strace "$1"; }` carries the section so that every later + call is one word shorter. Both end up here. + """ + sections = { + name: section + for name, section in re.findall( + r'^read_(\w+)\(\)\s*\{\s*read_pin (\w+) "\$1"; \}', script, re.M + ) + } + wanted = set(re.findall(r"read_pin (\w+) (\w+)", script)) + for name, section in sections.items(): + wanted |= {(section, key) for key in re.findall(rf"read_{name} (\w+)", script)} + return wanted + + +def test_the_rootfs_build_only_asks_for_keys_the_pin_has(): + pin = tomllib.loads(ROOTFS_PIN.read_text(encoding="utf-8")) + for section, key in sorted(asked_for(BUILD_SH.read_text(encoding="utf-8"))): + assert section in pin, f"build.sh reads a [{section}] section the pin has not got" + assert key in pin[section], f"build.sh reads {section}.{key} and the pin has not got it" + + +def test_the_helper_finds_both_spellings(): + """The test above is worth nothing if the reader misses half the calls, so here is a fixture.""" + script = 'read_strace() { read_pin strace "$1"; }\nA=$(read_pin busybox url)\nB=$(read_strace version)\n' + assert asked_for(script) == {("busybox", "url"), ("strace", "version")} + + +def test_everything_the_image_carries_is_pinned_by_checksum(): + """Two things come off the network into this image and both are checked before they are used. + + The busybox is a binary somebody else built and the strace is a source tarball, which is a real + difference in what the checksum is protecting. It is not a difference in whether there is one. + """ + pin = tomllib.loads(ROOTFS_PIN.read_text(encoding="utf-8")) + fetched = [name for name, row in pin.items() if isinstance(row, dict) and "url" in row] + assert fetched, "nothing in the rootfs pin is downloaded, which cannot be right" + for name in fetched: + row = pin[name] + assert len(row["sha256"]) == 64, f"{name} has no usable sha256" + assert row["url"].startswith("https://"), f"{name} is fetched over {row['url'][:5]}" + assert row["recorded"], f"{name} does not say when its checksum was taken"