Skip to content

Adversarial review fixes, and the published image becomes the tested one - #2

Merged
MorningLightMountain713 merged 12 commits into
mainfrom
feat/adversarial-review-fixes
Aug 13, 2026
Merged

Adversarial review fixes, and the published image becomes the tested one#2
MorningLightMountain713 merged 12 commits into
mainfrom
feat/adversarial-review-fixes

Conversation

@MorningLightMountain713

Copy link
Copy Markdown
Collaborator

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.Notify was installed inside runChild, 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/2024 moved onto photos displaced all of photos — 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. inspect measured 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 with os.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.

--id and --root became 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. markerContents made 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-links looked for symlinks and for regular files with a second name. tar carries 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 without O_NONBLOCK waits for a writer that never comes. The flag is renamed to --ordinary-only because a flag called --no-links that 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 docker and a built image, so they were never in it.

cp supports -T grepped --help for -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 to main racing for the same tag.

Also here

README.md gains 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 volume nosuid. Backed by container tests rather than asserted, because that is how most of the above shipped.

scripts/test.sh runs 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, gofmt over cmd and test, vet with and without the docker tag, and clean builds for linux/amd64, linux/arm64 and 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 passes bash -n, and the summary logic is proven against the real v1.0.0 — but job wiring and push-by-digest only 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.

MorningLightMountain713 and others added 12 commits August 13, 2026 18:02
…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>
@MorningLightMountain713
MorningLightMountain713 merged commit be65174 into main Aug 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant