Adversarial review fixes, and the published image becomes the tested one - #2
Merged
Merged
Conversation
…lock said so The marker recorded inode and mtime so the boot sweep could decide whether a publish completed. mtime cannot decide it: a reused inode means a new object with a new mtime, and an app writing into a directory it was just handed produces one too, so the two cases are indistinguishable and the reader refuses both. Creation time separates them. It survives rename(2), does not move when the object is written to, and there is no syscall that sets it, so unlike mtime the app owner cannot forge it. The record is self-describing because the kernel is not always able to supply one, and the caller cannot otherwise tell: libuv reports ctime as birthtime when there is nothing better, identical at creation and drifting on first write. Only the statx mask is authoritative, so identity() checks it and writes the clock it actually used. A reader seeing the old two-field record treats it as mtime and behaves exactly as today. Verified on kernel 6.17 against a real ext4 loop image and xfs, with a control proving the old identity moves on write where the new one does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The executor runs flux-op as PID 1, where the kernel delivers no signal the process has not asked for. signal.Notify was installed inside runChild, so a command-shaped operation handled a stop and an upload - which has no child at all - handled nothing: the TERM was discarded, the Go runtime gave up and exited 2, and nothing unwound. The deferred reclaim never ran, so a partial upload stayed at a name the reserved-name rules hide from the app owner, on a fixed-size volume, until the next boot sweep. FluxOS was told exit 2, which is this program's usage code. Asked for across the whole run now, which also stops a signal ending the process between the two renames of a publish. An upload cannot simply be interrupted where it stands. The read wakes when bytes arrive or the sender closes, and a stalled sender does neither; closing the descriptor does not help, because the runtime does not poll standard input and a read already blocked on it stays blocked - which is what left the container to be SIGKILLed at the end of its grace period. So the transfer is waited ON rather than waited FOR: it runs in its own goroutine and whichever arrives first wins. The abandoned reader holds a descriptor whose file is being unlinked and creates nothing, and the process is gone moments later. The container suite tested cancellation for the command path and not for the upload path, which is the asymmetry that let this live: the assertion that catches it, requireNoArtefacts, was already written. Its twin now exists, and fails without this - exit 2 and a leaked staging file before, exit 143 and a clean volume after, in 1.2s rather than a 15s SIGKILL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s now written The marker became self-describing when the sweep moved to creation time: it names the clock it used, so a volume that cannot supply a creation time degrades to the old behaviour instead of being compared against the wrong field. The unit helper was taught the three-field form; this one was not, so it built the old two-field string and could never match - on any filesystem, since both clocks produce three fields. It has been failing since that change landed. Nothing caught it because the run that reported the Go suite green was `go test ./cmd/...`; these need -tags docker and a built image, so they were not in it. Both clocks are still derived here rather than read back out of the marker - stat reports a birth time of zero exactly where statx leaves STATX_BTIME unset, which is the same condition flux-op decides on. Reading the clock out of the record would make the comparison agree with itself. Verified in both directions: the bind mount this suite runs on has no creation time and takes the fallback, and on a real Linux filesystem that does have one the helper and the marker agree byte for byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… displaced Publishing puts a result in place with two renames: the existing entry is slid aside under .flux-old-<id>, then the new one is renamed in. If the destination CONTAINS the staging path, the first rename carries staging away inside it and the second finds nothing - so a move of photos/2024 onto photos took the whole of photos with it, including entries the caller never named, and then reported failure. The mirror case cannot be renamed at all: rename(2) refuses to move a directory into its own subtree. Nothing is lost - the sweep restores it, because the destination is empty - but only at the next FluxOS restart, and until then the owner cannot see the parked copy or ask for it back: the reserved-name rules hide it from them. Reachable through move or copy with overwrite, where pair() refuses a destination inside the source but not the reverse. Rename is safe by construction, building its destination from the source's own directory. Refused rather than made to work. Completing it would delete everything else in the destination, which is the outcome this program exists to prevent, and a caller that means "move this up a level" asks for <parent>/<name> - an ordinary publish. The check is lexical because that is the question a rename answers. Three tests used this exact case to REACH the interrupted state, which is how the refusal went missing: the only way to fail the second rename was an operation that could never work. The unit tests now make staging's directory unwritable, which needs neither a second filesystem nor a mount, and skip as root rather than pass without testing anything. The container test asserts the refusal instead - under the configuration a node runs, with no extra mount, and the interrupted state left to the level that can build it honestly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… its path Two writes trusted a path the application can also write to. inspect() measured whatever it was pointed at, and nothing here follows a link - so a staging path that WAS a link measured as the few bytes of its own name, and a tree of any size behind it passed whatever --max-bytes it was given. A link inside the result was always refused; the result itself being one was not a case anything asked about. The marker was written with os.WriteFile, which follows. A link planted at that name would have taken the record of where the caller's data belongs somewhere else entirely, leaving the displaced copy with no marker beside it - which the sweep reads as a duplicate and deletes. It would also have written straight through whatever the link pointed at. Now O_NOFOLLOW, and O_EXCL as well: the name derives from an identifier that is fresh per operation, so anything already there is not this operation's to replace. Its close is checked rather than deferred and dropped, because a short marker is one the sweep cannot read, which is the case that costs the caller their data. Neither is reachable as things stand. FluxOS names both the staging path and --id with a randomUUID the application never learns, and the container does not exist until after they are chosen. That is the point: the safety of the ceiling and of the marker rested entirely on the CALLER having picked an unguessable name - an invariant this program neither states nor can check, and one a future caller could drop without anything here noticing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e paths Both are joined into paths and neither was looked at beyond being non-empty. --id names what an interrupted publish leaves at the volume root. A separator in it puts those artefacts in a subdirectory, and the sweep reads the root and nowhere else - so a displaced copy of the caller's data would sit there for ever, hidden from them by the reserved-name rules and reclaimed by nothing. Traversal in it leaves the volume altogether. It must now be the same shape the sweep matches on, so the writer and the reader cannot drift apart. --root must be absolute, since everything is built by joining onto it, and is refused rather than cleaned when cleaning would change where it points: a root of /work/../etc does not lead where it says. markerContents made its path relative by trimming a prefix, and trimming a prefix that is not there is a no-op - so a destination outside the root was written down as an ABSOLUTE path, which is the one shape the function exists to make unrepresentable. Its three tests all used destinations under the root, so the failing shape was never asked about. It refuses now, and publish stops rather than displacing something it cannot record the place of. None of this is reachable today: FluxOS passes a fresh randomUUID and a fixed /work. That is the point - the guarantee lived entirely in the caller, unstated and unchecked here, and a caller could drop it without anything noticing. Also fixes the usage test that never reached its own check: "input and a command together" wrote the command as an extra operand, so it was refused for missing its -- separator instead. Removing the check it names now fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…only links A FIFO is not a link, so nothing asked about it: --no-links looked for symlinks and for regular files with a second name, and a FIFO is neither. It published onto the volume, and whatever opens one without O_NONBLOCK waits for a writer that never comes. tar both carries and recreates them, and this flag is used for exactly one operation - extracting an archive - so an archive was all it took. Verified in a container configured as a node configures one: exit 0, and a prw-r--r-- in the destination. Device nodes never got this far, because CAP_MKNOD is dropped. FIFOs and sockets need no capability at all. The check is now what the flag always meant: a directory is the shape of the result itself, a regular file with one name is data, and everything else is refused. Renamed with it - a flag called --no-links that refuses FIFOs tells the next reader something untrue, and the image and FluxOS are pinned together so they can only move as a pair. Severity is lower than it was: FluxOS's own reader now opens with O_NONBLOCK and refuses anything that is not a regular file, so it answers rather than hanging. What was left was a stated guarantee that was not met, for readers less careful than that one - including code nobody has written yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s it Extraction is the one operation that runs attacker-supplied structure rather than attacker-supplied bytes, and the answers were scattered across code comments or nowhere at all. The useful column is the last one. Several of these are not this program's doing: traversal, absolute member names and writing through a symlink are all refused by GNU tar, and the setuid bit is left alone here because FluxOS mounts the volume nosuid - one place that covers every route such a file arrives by, including a plain copy. Recording that stops the next reader assuming a guard exists here that does not, and it is a concrete reason the GNU pinning matters: busybox tar is not the same program. Backed by tests rather than asserted, for the same reason everything else here is. The tar-owned rows are the ones most worth having: nothing in this repository would otherwise notice Alpine's packaging changing under it. The write-through test checks the target from INSIDE the same container, because a fresh one has a fresh /etc and would prove nothing. Its fixture was verified to carry both members - a symlink and a write through it - so it is exercising the attack rather than a malformed archive. Size is deliberately not in the table. The ceiling is applied to what landed rather than to what an archive declares, but stating a bound is not proving one, and --max-bytes measures apparent size - so it goes in once that is true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--max-bytes summed each entry's own reported length. A file occupies whole blocks, so twenty thousand one-byte files are 20KB by that measure and 82,436,096 bytes on a real ext4 volume - measured, not estimated. Four thousand times understated. That matters because of what the ceiling IS. FluxOS passes availableBytes / 1.05, so the limit is the volume's free space, counted in blocks; the figure it was compared against counted something else entirely. The check also runs after the command, deliberately, because an archive's declared size is written by whoever built it - so the sequence was: the extraction fills the disk, the result measures 20KB, the ceiling is satisfied, and it publishes. The guard did not merely under-measure, it failed to fire in exactly the case it exists for. Occupied blocks now, which is what `du` reports by default. Both sides of the comparison, and the progress figure the caller is shown, are the same kind of number for the first time. This reverses a stated decision rather than correcting an oversight - the old comment said the ceiling "has always been expressed against" apparent size. The reversal is right because the ceiling is free space rather than a quota: only the occupied figure answers "will this fit". A sparse file consequently measures as what it occupies rather than as its length. That is the same reasoning: the promise is that an operation will not fill the volume, and a sparse file does not. An application can write into its own holes afterwards, but it can write to its own volume at any time regardless, so nothing new becomes possible. max(apparent, occupied) was considered and rejected - refusing an operation that would genuinely have fitted is the worse failure when the limit is the caller's own free space. Proven both ways: the container test extracts a tree that reports kilobytes and occupies megabytes, against a ceiling between the two. It is refused now, and with the old measurement it publishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…was for "cp supports -T" grepped `cp --help` for -T. busybox lists -T, implements it, and passes the functional test beside it - so the one case whose whole purpose was to fail if coreutils were dropped passed without it. Demonstrated rather than argued: an image built without coreutils passes both the old assertions and fails the new one. What the image depends on here is not a missing flag. The Dockerfile says so - coreutils is installed for predictable GNU semantics, and busybox does implement -T - so the implementation is what has to be asserted, and `cp --version` naming GNU coreutils is what asserts it. mv is now held to the same thing. It is never driven: a move is a publish whose source is already the result, so flux-op renames rather than running anything, and the only mv calls in FluxOS are host-side mongod config. It is in the Contract because the image promises it, and an unchecked promise is exactly how the cp case came to be vacuous. The functional -T test keeps its place and loses its claim: it proves the behaviour a copy depends on, and it is not a coreutils detector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workflow built twice: once in the job that ran the container tests, and again in the job that pushed. Nothing connected them, and nothing had to - the Dockerfile pins a minor Alpine tag and installs unpinned apk packages, so two builds minutes apart are not required to agree. What shipped was a build nothing had ever run. Each architecture is now built once, pushed by digest with no tag on it, then pulled back out of the registry and tested through it. A final job assembles the manifest list from the digests that passed and builds nothing itself. A digest with no tag is not published in any useful sense - nothing can resolve to it without already knowing it - so a failed run leaves an unreferenced digest and no tag naming bytes that nothing ran. Pull requests still build locally and push nothing: there is nothing to publish, and a fork's token could not push it anyway. The summary now prints the whole pin rather than the manifest list digest. FluxOS records a tag AND a per-architecture image id, and an id is the digest of that image's own config - which cannot be derived from the list digest, so the old summary could not fill in the file it existed to fill in. Verified against the published v1.0.0: the same commands reproduce the two ids currently pinned. Actions are pinned to commits. A major tag is mutable and every job here holds packages: write, so a moved tag is a compromised release of the program that runs as root over an application's volume. A concurrency group per ref, because two pushes to main would otherwise race for the same tag and the later-finishing one wins rather than the newer one. In-progress runs are cancelled for pull requests only: killing a publishing run between its pushes and its manifest list leaves the digests unreferenced. gofmt now covers test/ as well as cmd/, and vet runs a second time with -tags docker - the container tests are behind that tag, so nothing compiled them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The container helper read the exit code by taking everything after its marker and parsing it as a number. stdout and stderr arrive on one stream there, and which lands last is buffering rather than order - so a run that writes a diagnostic to stderr can put it AFTER the marker, and the parse then swallows it. It failed on amd64 and passed on arm64 in the same run, which is the signature. The first line after the marker is what it needs, and that does not depend on interleaving. Pre-existing: any test using the helper could have hit it at any time. The symlink bound was mine. It caps the measurement to prove the walk did not follow the link, and 10,000 had no room left in it once entries are measured by what they occupy: on ext4 a three-entry tree is 12,288 bytes of blocks, while APFS reports none for a directory at all - so the same figure passed here and failed in CI. Raised to 50,000, which is an order of magnitude below the 100,000 sitting behind the link and four times above the floor. Reproduced rather than guessed at: run in a Linux container, a directory reports 8 blocks where the local one reports none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eleven changes from an adversarial review of the FluxOS side (RunOnFlux/flux#1778), plus the release machinery that has to be right before any of them ship.
Each fix was proven red before green, and the container suite was run against a locally built image throughout.
What was wrong
A stop never reached an upload.
signal.Notifywas installed insiderunChild, so an operation expressed as a command handled a stop and an upload — which has no child at all — handled nothing. flux-op runs as PID 1 in the executor's container, and the kernel delivers no signal to PID 1 that the process has not asked for: the TERM was discarded, the Go runtime gave up and exited 2, and nothing unwound. A partial upload was left at a name the reserved-name rules hide from the app owner, on a fixed-size volume, until the next boot sweep.Closing stdin does not help either — the runtime does not poll standard input, so a read already blocked on it stays blocked and the container is SIGKILLed at the end of its grace period. The transfer is now waited on rather than waited for.
Publishing a directory over its own parent took the whole parent with it. Displacing the destination carries the staging path away inside it, so the second rename finds nothing.
photos/2024moved ontophotosdisplaced all ofphotos— including files the caller never named — and then reported failure. Refused now, in both directions; the mirror case cannot be renamed at all.Two writes trusted a path the application can also write to.
inspectmeasured whatever it was pointed at, so a staging path that was a symlink measured as the few bytes of its own name and passed any ceiling. The marker was written withos.WriteFile, which follows a link. Neither is reachable while the caller names operations with a fresh UUID — which is the point: the safety rested entirely on the caller having chosen an unguessable name, an invariant this program neither states nor can check.--idand--rootbecame paths without being looked at. A separator in the identifier puts the artefacts in a subdirectory the boot sweep never reads; traversal puts them outside the volume.markerContentsmade its path relative by trimming a prefix, and trimming an absent prefix is a no-op — so a destination outside the root was written down as an absolute path, the one shape that function exists to make unrepresentable.A FIFO is not a link, so nothing asked about it.
--no-linkslooked for symlinks and for regular files with a second name.tarcarries and recreates FIFOs, and this flag is used for exactly one operation — extracting an archive — so an archive was all it took. Whatever opens one withoutO_NONBLOCKwaits for a writer that never comes. The flag is renamed to--ordinary-onlybecause a flag called--no-linksthat refuses FIFOs tells the next reader something untrue.The ceiling measured what files say rather than what they occupy. A file occupies whole blocks, so twenty thousand one-byte files are 20 KB by that measure and 82,436,096 bytes on a real ext4 volume — measured, not estimated. The ceiling is the volume's free space, counted in blocks, so the comparison was between two different kinds of number. The check runs after the command, so the sequence was: the extraction fills the disk, the result measures 20 KB, the ceiling is satisfied, and it publishes.
Tests that were not testing anything
The container suite had been failing since the identity work landed and nothing caught it, because the run that reported the Go suite green was
go test ./cmd/.... These need-tags dockerand a built image, so they were never in it.cp supports -Tgrepped--helpfor-T. busybox lists it, implements it, and passes the functional test beside it — so the one case whose whole purpose was to fail if coreutils were dropped passed without it. Demonstrated rather than argued: an image built without coreutils passes both old assertions and fails the new one.The cancelled-upload case did not exist. Cancellation was tested for the command path, and
requireNoArtefacts— the assertion that catches the leak — was already written. Its twin now exists.Two tests used a bug to reach the state they wanted. The interrupted-publish fixtures relied on the parent case failing, which is how the missing refusal survived. They now reach it by making staging's directory unwritable, and skip as root rather than passing without testing anything.
Release machinery
The image that was published was not the image that was tested. The workflow built twice — once in the job that ran the container tests, and again in the job that pushed. The Dockerfile pins a minor Alpine tag and installs unpinned packages, so two builds minutes apart are not required to agree.
Each architecture is now built once, pushed by digest with no tag, then pulled back and tested through the registry. The manifest list is assembled from the digests that passed. A digest with no tag on it is not published in any useful sense, so a failed run leaves no tag naming bytes that nothing ran.
The run summary now prints the whole pin. FluxOS records a tag and a per-architecture image id, and an id is the digest of that image's own config — which cannot be derived from the manifest list digest, so the old summary could not fill in the file it existed to fill in. Verified against the published
v1.0.0: the same commands reproduce the two ids currently pinned.Actions are pinned to commits, since every job holds
packages: write. A concurrency group stops two pushes tomainracing for the same tag.Also here
README.mdgains what a hostile archive cannot do, with a column for which layer actually stops each thing — traversal, absolute member names and writing through a symlink are all refused by GNU tar rather than by us, and the setuid bit is left alone because FluxOS mounts the volumenosuid. Backed by container tests rather than asserted, because that is how most of the above shipped.scripts/test.shruns both halves in one command, and fails loudly if docker is missing rather than reporting a pass for half a suite.Verification
Unit suite with
-race, container suite on a locally built image,gofmtovercmdandtest,vetwith and without the docker tag, and clean builds forlinux/amd64,linux/arm64and darwin — all green via./scripts/test.sh.The workflow itself is the one thing that cannot be verified locally. Its YAML parses, every
run:block passesbash -n, and the summary logic is proven against the realv1.0.0— but job wiring andpush-by-digestonly prove themselves on a real run. The publish path is skipped on pull requests by design, so this PR exercises the build and test path only.