Skip to content

Refuse a multicast stream that is not the one this partition asked for - #178

Merged
mastacontrola merged 1 commit into
masterfrom
fix/multicast-stream-identity
Sep 9, 2026
Merged

mastacontrola merged 1 commit into
masterfrom
fix/multicast-stream-identity

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

Client half of the fix for FOGProject/fogproject#1742. Pairs with the
server change in fogproject (link below) — see Ordering at the end.

The bug

udpcast carries no metadata. A stream is bound to a partition by nothing but
its position: the server chains one udp-sender per image file on a shared
portbase, FOS opens one udp-receiver per file it expects, and the Nth
receiver gets the Nth stream.

So a client that reboots mid-session — or whose receiver opens after a sender
has already stopped waiting for it — lands one stream out of step, and every
partition after that is restored from the wrong image.

partclone objects only when the target partition is smaller than the
source. In the reported run partition 3's image (135 MB MSR) was caught on
its way onto partition 2 (105 MB EFI) — but only after partition 2's image
had already gone onto partition 1 with no error at all, because that target
was 300 MB. A larger target means the wrong filesystem is written and the
deploy reports success.

What this does

writeImage reads a fixed 128-byte header off the receiver, before the
decompressor is started, and refuses a stream that is not the file it was
asked for. A refusal therefore leaves the target partition untouched.

The expected name is $file — the argument writeImage was already being
handed and discarding on the multicast path. It is now the assertion, and
the Args Passed: line in an error stops being decorative.

Three details are forced rather than chosen:

  • Fixed width, because a count of bytes is the only thing a pipe lets you
    read without consuming payload.
  • bs=1, because a pipe may return a short read and over-reading eats the
    front of the image.
  • Only stripped when the server advertises mcstreamid via
    getversion.php?caps=1 — taking 128 bytes off a server that does not
    prepend them would corrupt every deploy. The call is checked before its
    answer is read, so an unreachable server does not read as "no header"
    (the GH-1266 shape, same as the mclvm probe beside it).

Naming

The id is the basename, except a stream carrying several files is named by
the stem they share — which is exactly what the client's own glob collapses
to:

Layout Client asks for Id
flat partition d1p2.img d1p2.img
split chunks d1p1.img* d1p1.img
sys.img.*one partition sys.img.* sys.img
rec.img.000 / .001two partitions each by name rec.img.000

The sys/rec asymmetry is FOS's own and the server already mirrors it when
deciding what to concatenate (#897). Both sides reach the same string from
their own layout, so neither re-derives the other's ordinal — the
re-derivation ADR-0007 argued against.

Verification

tests/checks/multicast-stream-identity.sh — 17 checks. It greps the wiring,
because the ordering of "open the receiver, read the header, only then
restore" is what makes a refusal safe and there is nothing to execute that
would show it; and it executes the real function against synthetic headers
for the naming rule, because that rule is not uniform.

Seven mutations, each failing it: the call removed; the check moved after the
payload starts flowing; each of the two suffix strips dropped; the comparison
neutered; one byte short on the read; the capability guard removed.

Writing that harness found a defect in the check itself — the right side of
[[ != ]] is a pattern, so an unstripped d1p1.img* matched the stem it
was meant to be compared against. Quoted, and the mutation that exposed it
now fails two checks instead of one.

Against real udpcast, driving the server's own emitted command and this
function over a loopback:

Case Result
flat partition asked for by name accepted, payload sha256-identical after the header
split partition asked for by glob accepted, payload sha256-identical (both chunks)
partition 3's stream offered to partition 2 refused, names the desync

Full FOS suite: 21 passed, 0 failed.

Ordering

The server prepends the header unconditionally, so this needs to ship with
the server change, not after it
. An old FOS against a new server feeds 128
bytes into the decompressor and fails there — a clean failure, not
corruption — and in practice FOS is served by the server it is talking to.

Rationale, alternatives (per-stream ports, and why the port budget and
ADR-0007 both rule them out) and the known gaps are in
docs/adr/0018-multicast-streams-identify-themselves.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A767exFmz6sQUcuZofqE1V

udpcast carries no metadata, so a stream is bound to a partition by nothing
but its position in the sequence: the server chains one udp-sender per image
file on a shared portbase, FOS opens one udp-receiver per file it expects,
and the Nth receiver gets the Nth stream.

A client that reboots mid-session -- or whose receiver opens after a sender
has already stopped waiting for it -- therefore lands one stream out of step,
and every partition after that is restored from the wrong image. partclone
objects only when the target partition is SMALLER than the source; when it is
larger the wrong filesystem is written and the deploy reports success.
fogproject #1742 records a run where partition 3's image was caught on its
way onto partition 2 (105 MB target, 135 MB source) only after partition 2's
had already gone silently onto partition 1 (300 MB target).

The server now introduces every stream with a fixed 128-byte record naming
the image file it carries. writeImage reads it off the receiver before the
decompressor is started -- so a refusal leaves the target untouched -- and
compares it against the file it was asked for. That argument was already
being passed in and thrown away on the multicast path; it is now the
assertion.

Three details are forced rather than chosen. The record is fixed width
because a count of bytes is the only thing a pipe lets you read without
consuming payload. It is read with bs=1 because a pipe may return a short
read and over-reading eats the front of the image. And the header is only
stripped when getversion.php?caps=1 advertises mcstreamid, because taking 128
bytes off a server that does not prepend them would corrupt every deploy --
the call is checked before its answer is read, so an unreachable server does
not read as "no header" (the GH-1266 shape, same as the mclvm probe beside
it).

The name is the basename, except that a stream carrying several files is
named by the stem they share, which is what the client's own glob collapses
to. So d1p1.img* and sys.img.* both become the stem while rec.img.000 and
rec.img.001 keep their names -- the sys/rec asymmetry is FOS's own and the
server already mirrors it when deciding what to concatenate. Both sides reach
the same string from their own layout; neither counts the other's streams.

tests/checks/multicast-stream-identity.sh greps the wiring, because the
ordering of "open the receiver, read the header, only then restore" is what
makes a refusal safe, and executes the real function against synthetic
headers for the naming rule, because that rule is not uniform. Seven
mutations -- the call removed, the check moved after the payload starts
flowing, each of the two suffix strips dropped, the comparison neutered, one
byte short on the read, the capability guard removed -- each fail it.

Writing that harness found a defect in the check itself: the right side of
[[ != ]] is a pattern, so an unstripped d1p1.img* matched the stem it was
meant to be compared against. Quoted.

Verified against real udpcast on a loopback, driving the server's own emitted
command and this function: a flat partition asked for by name and a split one
asked for by glob both accepted with the payload byte-identical by sha256
after the header, and partition 3's stream offered to partition 2 refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A767exFmz6sQUcuZofqE1V
@mastacontrola
mastacontrola merged commit 7312cac into master Sep 9, 2026
1 check passed
@mastacontrola
mastacontrola deleted the fix/multicast-stream-identity branch September 9, 2026 13:25
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