PLAN: Differencing phase 5 — the plan_vhd differencing emitter¶
Phase 5 of PLAN-differencing.md.
Goal¶
Make create::plan_vhd able to emit a differencing VHD: footer
disk_type = 4, a dynamic header carrying the parent's unique id,
timestamp and unicode name, and exactly one populated parent
locator entry with its platform data written into the file.
This phase is the emitter only. It changes a crate function and
the format crate underneath it; it does not make
instar create -f vpc -b parent.vhd work, because the guest has
nowhere to get the parent's identity from until phase 7 reads the
parent's footer. The proof that this phase worked is instar's own
phase 3 parser reading back what this phase wrote.
Planning effort¶
High, as the master plan requires for this phase. The judgement is not in any single field — it is in the layout (the locator data region is new file real estate, and the parser already constrains where it may sit) and in two encoding traps the format sets, both of which are pinned by measurement and neither of which any external tool will catch for us.
Review effort¶
High, concentrated on one question: is every emitted byte at the
offset and in the endianness the format actually uses? The
master plan's standing warning applies at full force here —
libvhdi never parses the VHD locator table at all, and qemu-img
reads only the parent unicode name, so a wrong locator is
invisible to both oracles. A reviewer should check the emitter
against SPEC(VHD) and the phase 1 pin directly, not against this
plan's restatement of them.
Scope¶
In scope:
src/crates/vhd/src/lib.rs— a parent-aware dynamic header builder, a locator entry builder, and a UTF-8 → UTF-16 encoder that is the inverse of the existingshared::utf16_to_utf8.src/crates/vhd/src/lib.rs— expose the footer timestamp, which the struct does not currently carry.src/crates/create/src/lib.rs—plan_vhdaccepts a backing reference, gains the parent-identity inputs it cannot derive, and grows a locator data region in its layout.- Rust unit and round-trip tests, including emit-then-parse against the phase 3 parser.
Out of scope, and each named with the phase that owns it:
- VHDX — phase 6. The two emitters are independent; this one goes first because the VHD locator table is the simpler structure.
- The guest create op and the host CLI — phase 7.
-bon a vpc target keeps returningBackingFileUnsupportedto the user until phase 7 lands. Two small mechanical changes tosrc/operations/createand one tosrc/vmmare unavoidable and are in scope: theVhdCreateOptsliteral, the explicit guard of decision 8, and the new error code'smap_create_errorarm and host message. None of them changes what a user sees. That is a deliberate intermediate state: the emitter is reviewable on its own, and wiring it before it is reviewed would mean shipping a format writer whose output nothing had read back. - Python integration tests — phase 8. This phase's tests are Rust, in-crate, and do not need a VM.
- Coverage fuzzing — phase 9, with one exception noted under
risks:
fuzz_create_emittersalready feeds abackingintoplan_vhd, so this phase changes what that target reaches whether it wants to or not. - User-facing documentation — phase 10 owns the cross-cutting
pages.
docs/create.md's current statement that vpc rejects-bstays true through this phase and must not be edited. - Composition on read — phases 11 to 16. Anything this phase writes is still refused by phase 4's read-side policy, which is correct and expected.
- Giving created VHDs a real footer UUID — see decision 2, filed as #566.
What the survey found¶
The master plan's phase 5 claims were checked against the tree at
f981374. No claim of substance was wrong. Every fact the
plan asserts about plan_vhd still holds. What had rotted was
navigation: nine of the file:line references the master plan
carries were moved by phases 3 and 4, which between them added
roughly 1,500 lines to crates/vhd and crates/vhdx and
rewrote parts of src/vmm/src/main.rs. Those are corrected at
source in the same commit as this plan, so this section records
the check rather than the fix:
| Master plan said | Actually at |
|---|---|
crates/vhdx/src/lib.rs:1341-1345 (build_header DataWriteGuid) |
:2201-2212 |
crates/create/src/lib.rs:964-966 (plan_vhdx sequence numbers) |
:965, :967 |
crates/vhd/src/lib.rs:664-673, :766 (bitmap skip) |
:1432-1441, :1534 |
crates/vhdx/src/lib.rs:654 (sector bitmap skip) |
:1820 |
crates/vhdx/src/lib.rs:571, :581, :601 (PARTIALLY_PRESENT) |
:1408, :1438, :1487 |
vmm/src/main.rs:16612 (run_create_nonraw) |
:16815 |
vmm/src/main.rs:16710-16769 (typed_backing) |
:16913-16963 |
vmm/src/main.rs:2416 (discover_backing_chain) |
:2501 |
Verified unchanged and still correct: crates/create/src/lib.rs:776
and :820 (UUID_ZERO), operations/convert/src/main.rs:4469
and :4492 (sequence numbers 1 and 2), vmm/src/config.rs:65
and :67 (the two security settings).
Five findings that are new, and that shape the step plan:
-
VhdFooterhas notimestampfield. The master plan says the guest can read the parent's footer "for its unique id and timestamp" without a new call-table primitive, and that is true — butVhdFooter(crates/vhd/src/lib.rs:182) exposesuuidand not the timestamp atFOOTER_TIMESTAMP_OFFSET(:48). Phase 7 would have to reach past the parser to get it. Adding the field belongs here, with the rest of this phase'scrates/vhdwork. -
There is no UTF-8 → UTF-16 encoder anywhere in the tree.
shared::utf16_to_utf8(src/shared/src/lib.rs:187) exists and is thoroughly tested in both endiannesses, including surrogate pairs and the refusal cases; nothing goes the other way. The emitter needs one, inno_std, panic-free, for two different endiannesses (see decision 3). -
MAX_BACKING_FILE_LENis 1024 bytes and the destination field is 512.crates/create/src/lib.rs:24caps a backing path at 1024 bytes for every format; the VHD parent unicode name field isDYN_PARENT_NAME_SIZE = 512bytes, which is 256 UTF-16 code units (crates/vhd/src/lib.rs:117). An ASCII path of 300 bytes passes the generic check and does not fit. The VHD path needs its own, tighter limit and must fail loudly rather than truncate — a truncated parent name is a path to a different file. -
The parser already constrains where the locator data may sit.
locator_defect(crates/vhd/src/lib.rs, reached fromVhdParentLocator::parse:507) rejects platform data that overlaps the head footer (0..512), overlaps the dynamic header (header_offset..header_offset+1024), runs pastimage_len, or hasplatform_data_length > platform_data_space. Decision 1's layout is the one that satisfies all four, and emit-then-parse is therefore a real check rather than a tautology. -
fuzz_create_emittersalready passes abackingtoplan_vhd(src/fuzz/fuzz_targets/fuzz_create_emitters.rs:179-181). Today it can only ever getBackingFileUnsupportedback. The momentplan_vhdaccepts a backing, the fuzzer starts driving the new layout against its existing oracles —plan.minimum_file_sizeand the non-overlapping-writes assertion. That is free coverage and a free trap: those oracles must hold for the new region too.
One more, recorded because it is the reason a later phase's test
cannot be written the obvious way. plan_vhd writes UUID_ZERO
as the footer unique id of every image it creates
(crates/create/src/lib.rs:776, used at :820, :841, :875).
A differencing child copies its parent's unique id into the
dynamic header, so an instar-created parent gives an all-zero
parent_unique_id, and any instar parent satisfies any instar
child. The master plan flags the equivalent VHDX hole for
phase 6; the VHD half is this phase's to notice and is handled by
decision 2.
Decisions¶
1. The locator data region goes between the dynamic header and the BAT, one sector wide, at a fixed offset of 1536.
The layout plan_vhd emits today is footer copy at 0, dynamic
header at 512, BAT at 1536, tail footer after the BAT. The locator
data has to go somewhere, and there are three candidates: before
the BAT, after the BAT, or inside the 1024-byte dynamic header's
unused tail. The last is wrong — the header is a fixed 1024 bytes
and locator_defect explicitly refuses data overlapping it.
Between header and BAT is chosen over after-the-BAT for two
reasons. It keeps every metadata structure in a contiguous prefix,
so minimum_file_size arithmetic stays a running sum with no
hole; and it matches what both measured Hyper-V images do, which
costs nothing to match and is one less way to look unusual to a
reader we cannot test against.
One sector, not more. The maximum platform data this phase can
emit is a 256-code-unit path, 512 bytes of UTF-16 — exactly one
sector. platform_data_space is therefore 512 and
platform_data_length is the actual encoded byte count, which
satisfies the parser's length <= space rule with the slack in
the right direction. The BAT moves from 1536 to 2048 for a
differencing child and stays at 1536 for a non-differencing one;
the implementer must not "simplify" this by moving the BAT
unconditionally, because that would change the bytes of every
non-differencing VHD instar writes and break golden comparisons
that have nothing to do with this plan.
2. This phase does not give created VHDs a real footer UUID. Filed as #566 instead, with the consequence recorded.
This is the decision most likely to be argued with, so the reasoning is set out in full. The all-zero unique id makes the parent-identity check vacuous for chains instar wrote end to end, and it is tempting to fix it here, in the emitter, where it is visible. Three things argue against.
It is not a differencing defect. Every VHD instar has ever
created has a zero unique id, dynamic and fixed alike, and
changing that changes the bytes of every create -f vpc
invocation — including the ones tests/test_create.py compares
against qemu-img. That is a blast radius belonging to its own
change, not to a rider on an emitter.
There is no entropy in the guest. A grep for a random source
across src/shared and src/crates returns nothing: the guest
has no RNG, and a real UUID would have to be generated on the
host and passed through the call table — which is precisely the
ABI change the master plan's phase 7 rationale says this plan does
not need. Making that change for a cosmetic field, inside the
phase that is meant to be the simpler of the two emitters, is the
wrong trade.
And it would not buy a test. The master plan already settles this
for VHDX: phase 8's negative identity test "must be built against
a third-party parent either way, because an instar-created
parent cannot fail it." A real UUID in plan_vhd would make
instar-to-instar chains verifiable, but the test that matters —
does instar reject a child whose parent id disagrees? — is a
phase 11-to-16 read-side question and will use a Hyper-V fixture
regardless.
So: #566 covers both formats and is linked from the master plan's
Future work, and phase 8's brief must say the negative identity
test uses a third-party parent. The emitter writes
whatever parent_unique_id the caller hands it and does not
second-guess an all-zero one, because an all-zero id is what a
faithful child of an instar parent has.
3. The two endiannesses are named at every call site, and the encoder takes the flag rather than defaulting.
The VHD format uses UTF-16 big endian for the parent unicode
name at header offset 64, and UTF-16 little endian for the
W2ru/W2ku platform data. Phase 3's parser documents this in
two places (crates/vhd/src/lib.rs:104 and :588) precisely
because it is the trap. The encoder this phase adds therefore
takes an explicit big_endian: bool, exactly as
shared::utf16_to_utf8 does, and neither call site is allowed to
rely on a default. A unit test asserts the same path encodes to
two different byte strings, so a future refactor that unifies them
fails rather than silently writing a name no Windows reader can
resolve.
4. platform_data_space is written in bytes, not sectors.
SPEC(VHD)'s wording implies a 512-byte sector count. It is
wrong, and phase 1 pinned it: the sector reading is arithmetically
impossible on both measured Hyper-V images, and phase 3's parser
documents the field as "A byte count, not the 512-byte sector
count SPEC(VHD)'s wording implies" (crates/vhd/src/lib.rs:474).
The emitter writes 512, meaning 512 bytes. This decision exists as
its own numbered item because writing 1 here is the single most
plausible wrong value in the whole phase, it round-trips through
nothing that would catch it, and no external oracle looks at this
field at all.
5. A differencing child is Dynamic-only; Fixed plus a backing
is rejected.
disk_type = 4 and a fixed layout are incompatible — a
differencing disk needs a BAT and a dynamic header to say which
blocks are the child's, and a fixed VHD has neither. Hyper-V
produces no such image and SPEC(VHD) describes none. The
VhdSubformat::Fixed arm of plan_vhd returns
CreateError::BackingFileUnsupported for a backing reference,
which is the existing error with its existing meaning: this
target format, in this subformat, does not support backing files.
6. The over-length parent path gets its own error, and the limit is 255 UTF-16 code units, not 256.
Both halves of this decision reverse what this plan originally
said. It was written as "reuse BackingFileTooLong, cap at 256",
and implementation falsified both. The original reasoning is kept
below each correction so the reversal can be judged rather than
taken on trust.
The limit is 255. The plan said 256 succeeds and 257 fails.
Phase 1 pinned 255, with a measured reason this plan did not
account for: a path of exactly 256 code units fills all 512 bytes
with no room for a terminating NUL, and that is precisely the
image that trips libvhdi defect C — the oracle reads past the
field into the locator table and reports a parent filename with a
stray character appended. A 512-byte-inclusive rule would have
instar emitting output the only tool that resolves VHD parents
misreads. crates/vhd's own DYN_PARENT_NAME_SIZE doc comment
already recorded the 255 emit-side bound against the 256
parse-side bound; this plan contradicted a constant in the tree it
was planning against. The emitter hands the encoder two bytes less
than the field, so the cap is structural rather than a comparison
that can be got wrong, and the last code unit is always zero.
It gets its own error. The plan argued that a new variant costs
an append-only CreateResult code and buys nothing a user can act
on differently. That is wrong on the facts: the host renders
ERROR_BACKING_TOO_LONG as "backing file path too long (max
1024 bytes)" (src/vmm/src/main.rs:17541), so reusing it would
tell a user that their 300-byte path exceeded a 1024-byte limit.
That is a false statement in a diagnostic — the same class of
misdiagnosis as #548, argued at length in phase 4's review, and
reusing the code here would have reintroduced it one phase later.
So: a new CreateError::ParentNameTooLong, a new appended
CreateResult::ERROR_PARENT_NAME_TOO_LONG, a map_create_error
arm and a host message that names the real limit.
The check lives in crates/vhd's builder rather than in
plan_vhd, because that is where the encoding happens and a
second implementation of the same limit is a second chance to
disagree with it. Counting is done during encoding; it is never
estimated from the UTF-8 length, which is neither 255 nor 510
bytes once any character outside the BMP appears.
7. Both the parent unicode name and the locator entry are written, always.
They are redundant for any single reader and neither is optional.
libvhdi resolves a VHD parent from the unicode name field alone
and never looks at the locator table; qemu's block/vpc.c does
the same. Windows uses the locator. Writing only the name would
produce a child Hyper-V cannot open; writing only the locator
would produce one neither of our oracles can open, which would
also make phase 8 untestable. Open question 3 already settled
which locator entry — one, in slot 1, W2ru for a relative
typed path and W2ku for an absolute one, slots 2 to 8 zero — so
this decision only records that the name field is not dropped in
favour of it.
8. The guest keeps refusing create -f vpc -b, by an explicit
guard that phase 7 removes.
Forced by the same discovery as decision 6's scope correction.
Once plan_vhd accepts a backing, the guest create op reaches the
new emitter the moment a user passes -b on a vpc target — the
call at src/operations/create/src/main.rs:668 is already there
and already forwards backing_ref. Letting that through would
emit a differencing VHD whose parent_unique_id is whatever
vhd_opts_from invented, and since the guest cannot read the
parent's footer until phase 7, that means zero.
Zero happens to be right for an instar-created parent, because
every VHD instar writes has an all-zero footer id (#566). It is
wrong for every third-party parent, and a child pointing at a
Hyper-V parent with a zeroed identity is exactly the silently
wrong output this whole plan exists to stop. So vhd_opts_from
passes zeros and the guest arm refuses vpc-plus-backing before
calling the planner, with CreateError::BackingFileUnsupported —
the error and the message a user gets today, so the phase is
invisible from outside. The guard carries a comment naming phase 7
as the step that removes it.
The alternative — leave the phase's own emitter unreachable by not adding the fields at all — is not available, because the fields are what makes the emitter testable.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 5a | medium | sonnet | none | Add a UTF-8 → UTF-16 encoder to src/shared/src/lib.rs, beside utf16_to_utf8 (:187), with the mirror-image signature: pub fn utf8_to_utf16(src: &str, big_endian: bool, dst: &mut [u8]) -> Option<usize>, returning the number of bytes written, None if dst is too small or the encoding overflows 256 UTF-16 code units is not this function's business — length policy belongs to the caller, so return None only for a dst that cannot hold the result. src/shared is no_std, panic-free, no allocator: use char::encode_utf16 via a manual surrogate split rather than anything allocating, and index dst with explicit bounds checks. Test it as the inverse of utf16_to_utf8 over the same cases that function already tests (:6441-6690): ASCII in both endiannesses, a surrogate pair, a multi-byte BMP form, and a dst exactly one byte too small. Add one round-trip test per case asserting utf16_to_utf8(utf8_to_utf16(s)) == s. Touch nothing else. |
| 5b | medium | sonnet | none | Expose the VHD footer timestamp. In src/crates/vhd/src/lib.rs, add pub timestamp: u32 to VhdFooter (:182) and populate it in VhdFooter::parse (:202) from FOOTER_TIMESTAMP_OFFSET (:48, big-endian, seconds since 2000-01-01 00:00:00 UTC). This is additive and every existing construction site is inside parse, but build the whole workspace afterwards: VhdFooter is pub and struct-literal construction elsewhere would break. Add one unit test asserting the field is read from offset 24 and not from a neighbouring field, using a footer whose timestamp differs from its original_size and current_size. Do not change build_footer — the timestamp it writes is the creation time of the image being created, which is a different value from the parent's, and step 5c handles the parent's. |
| 5c | high | opus | worktree | Add parent-emitting support to src/crates/vhd/src/lib.rs. (i) A builder for the parent half of the dynamic header. Do not change build_dynamic_header (:1889) — add a second function that takes an already-built header buffer and fills the parent fields, so the non-differencing path is byte-identical to today and the diff shows that. It writes parent_unique_id (16 raw bytes at DYN_PARENT_UNIQUE_ID_OFFSET, :101), parent_timestamp (big-endian u32 at :103), and the parent unicode name (UTF-16 BIG endian at DYN_PARENT_NAME_OFFSET, :106, field size DYN_PARENT_NAME_SIZE = 512 bytes = 256 code units, :117), zero-padded to the full field, using 5a's encoder with big_endian = true. (ii) A builder for one locator entry, writing into the 24 bytes at DYN_PARENT_LOCATORS_OFFSET + slot * PARENT_LOCATOR_ENTRY_SIZE (:119, :124): platform code as four ASCII bytes in file order, not byte-swapped (:468), platform_data_space and platform_data_length as big-endian u32 at :131 and :133, reserved zero at :135, platform_data_offset as a big-endian u64 absolute file offset at :137. platform_data_space is a byte count — see decision 4. (iii) A helper that encodes the platform data itself: UTF-16 LITTLE endian (:588, the opposite of the name field), no NUL terminator (:481). (iv) Recompute the dynamic header checksum after the parent fields are written — compute_checksum (:995) with DYN_CHECKSUM_OFFSET (:90); a header whose checksum predates its parent fields is the defect this step is most likely to ship. Unit tests: assert each field lands at the documented offset by checking the raw bytes; assert the two endiannesses differ for the same path (decision 3); assert the checksum validates after the parent fields are written. |
| 5d | high | opus | worktree | Teach plan_vhd (src/crates/create/src/lib.rs:749) to emit a differencing child. Replace the BackingFileUnsupported early return at :767 with a split: VhdSubformat::Fixed plus a backing still returns it (decision 5); Dynamic plus a backing takes the new path. Add to VhdCreateOpts (:280) the two values the planner cannot derive — parent_unique_id: [u8; 16] and parent_timestamp: u32 — following how VmdkCreateOpts::parent_cid (:258-261) already carries a parent-derived value into a planner. Layout per decision 1: head footer 0, dynamic header 512, locator data 1536 (one sector), BAT 2048, tail footer after the BAT; the non-differencing layout is unchanged, BAT still at 1536. Both footers get vhd::DISK_TYPE_DIFFERENCING (crates/vhd/src/lib.rs:166) instead of DISK_TYPE_DYNAMIC. Pick the platform code from the typed path bytes: W2ru if relative, W2ku if it starts with / (open question 3 — one entry, slot 1, slots 2 to 8 left zero). The 255-code-unit limit is already enforced inside crates/vhd's builder (step 5c) — do not re-implement it here; map its VhdBuildError::ParentNameTooLong onto a new CreateError::ParentNameTooLong (decision 6). Because map_create_error (src/operations/create/src/main.rs:105) is an exhaustive match, that variant also needs an arm there, a new appended CreateResult::ERROR_PARENT_NAME_TOO_LONG beside ERROR_BACKING_DIFFERENCING in src/shared/src/lib.rs, and a host message in src/vmm/src/main.rs beside :17541 that names the real limit rather than 1024 bytes. Adding fields to VhdCreateOpts breaks seven struct-literal sites — update all of them, including src/operations/create/src/main.rs:310 and src/fuzz/fuzz_targets/fuzz_create_emitters.rs:175. Per decision 8, vhd_opts_from passes zeros for the parent identity and the guest's ImageFormat::Vhd arm refuses backing-plus-vpc before calling the planner, with BackingFileUnsupported, carrying a comment naming phase 7 as the step that removes it — so user-visible behaviour is unchanged by this phase. The plan gains a fifth MetadataWrite for the locator data; MAX_METADATA_WRITES is 96 (:122) and VHD_MAX_METADATA_SCRATCH is 4 MiB (:46), so neither is near a limit, but debug_assert_eq!(plan.minimum_file_size, total_file_size) at the end of the Dynamic arm must still hold. |
| 5e | high | opus | worktree | Tests, in src/crates/create/ and src/crates/vhd/. The load-bearing one is emit-then-parse: build a differencing plan, lay its writes into a byte buffer, and read it back with the phase 3 parser — VhdFooter::parse, VhdDynamicHeader::parse, and VhdParentInfo::parse (crates/vhd/src/lib.rs:936) with a VhdImageBounds { image_len, header_offset: 512 }. Assert: disk_type == 4; the decoded parent name equals the input path; the selected locator is the expected one with defect == None (which is what proves the layout satisfies locator_defect's four rules — survey finding 4); platform_data_space == 512 and platform_data_length equal to the encoded byte count. Add a negative-control test that a locator placed at 512 instead of 1536 comes back with OverlapsHeader, so the round-trip test is demonstrably not vacuous. Add: a relative path yields W2ru and an absolute one W2ku; a 256-code-unit path is rejected while a 255-code-unit one succeeds, and a non-BMP character costs two code units against that limit (127 fit, 128 do not) — the boundary tests for this already exist in crates/vhd from step 5c, so what 5e adds is that plan_vhd surfaces the refusal as CreateError::ParentNameTooLong rather than swallowing or re-deriving it; create -f vpc -b still fails exactly as it does on develop (decision 8); VhdSubformat::Fixed plus a backing still returns BackingFileUnsupported; and a byte-for-byte regression test that a non-differencing dynamic VHD is unchanged, comparing against the bytes plan_vhd produces on develop for the same options. Extend src/crates/create/tests/round_trip.rs (:284, :310) rather than starting a new harness, and update plan_vhd_rejects_backing (:1467), which this phase makes wrong. |
| 5f | medium | sonnet | none | Closeout. (i) Confirm fuzz_create_emitters (src/fuzz/fuzz_targets/fuzz_create_emitters.rs:179-181) still holds its oracles now that plan_vhd accepts a backing — build it and run it briefly against the new path; it needs no code change, but survey finding 5 means it is now reaching code it never reached before, and a minimum_file_size that disagrees with the writes will surface here first. (ii) Add a CHANGELOG.md entry for the crate-level capability, phrased so it does not promise a user-facing feature: create -f vpc -b still returns BackingFileUnsupported until phase 7. (iii) Confirm #566 (decision 2) is still open and still accurate against the tree at the end of this phase; it is already linked from the master plan's Future work, so this is a check, not a filing. (iv) Do not touch docs/create.md — its statement that vpc rejects -b is still true and phase 10 owns the change. |
Steps 5a and 5b are independent of each other and of everything else; both can run first. 5c depends on 5a and 5b. 5d depends on 5c. 5e depends on 5d. 5f last.
Risks and mitigations¶
-
A plausible-looking wrong offset or endianness ships, and nothing catches it. The defining risk of this phase: libvhdi never parses the VHD locator table, qemu-img reads only the parent unicode name, and no Windows host is in reach. A wrong
platform_data_space, a byte-swapped platform code, or the name field written little-endian would all produce a file that instar reads back perfectly. Mitigation: every offset and endianness in the 5c and 5d briefs is cited to a line in phase 3's parser, which was itself written against measured Hyper-V bytes; the management session re-reads those lines against the source before accepting the step, rather than against this plan. The emit-then-parse test in 5e is necessary but not sufficient — it shares its assumptions with the emitter — which is why 5e also carries a negative control. -
The non-differencing path changes by accident. Moving the BAT is a one-character difference between "only for a differencing child" and "always", and the second breaks every golden comparison in
tests/test_create.pyfor reasons no one reading the failure would connect to this plan. Mitigation: 5e's byte-for-byte regression test againstdevelop's output, and 5c's instruction to add a second header builder rather than modifybuild_dynamic_header, so the diff itself shows the old path untouched. -
The parent name is silently truncated. 1024 bytes pass the generic check and 512 bytes fit the field (survey finding 3). A truncated path names a different file, which is worse than a refusal in exactly the way phase 4 spent its whole review arguing. Mitigation: decision 6 puts the check in
plan_vhdon the encoded code-unit count, and 5e tests the boundary at 256 and 257 code units and with a non-BMP character. -
The dynamic header checksum is computed before the parent fields are written. Mechanically easy to get wrong, and invisible to instar's own reader:
VhdDynamicHeader::parsevalidates thecxsparsecookie and not the checksum, as phase 4's review established. qemu-img validates the footer checksum only. Mitigation: 5c orders the checksum last and tests it explicitly; 5e's round-trip cannot catch this, and the brief says so. -
The fuzz target starts failing on unrelated PRs. 5f runs it deliberately rather than waiting for the nightly to find it.
Found in review: this risk's mitigation, and survey finding 5,
both claimed fuzz_create_emitters carries a
non-overlapping-writes oracle. It did not — assert_invariants
checked bookkeeping, write containment, overflow and the write
count, and the overlap check lived only in
crates/create/tests/round_trip.rs::assert_plan_invariants. The
oracle has been added to the fuzz target rather than the claim
softened, because it now applies to all four emitters and the
differencing layout is exactly the kind of change that breaks it.
- The locator entry says "Windows" and holds a POSIX path.
Found in review, deliberately not fixed here — issue #570. The
platform code is chosen from the typed path's first byte, so
/srv/images/parent.vhdis emitted asW2ku, which SPEC(VHD) defines as an absolute Unicode pathname on Windows; measured Hyper-V output writes.\fat-parent.vhdandC:\Projects\...instead. A Windows reader could resolve instar's string drive-relative, and a user who types a Windows path gets it labelled relative. Mitigation: nothing user-reachable emits these bytes while decision 8's guard stands, and the parent unicode name field — the only one libvhdi and qemu actually read — is unaffected. Settling it means reopening open question 3, which no oracle in reach can arbitrate, so it is recorded indocs/quirks.mdand must be decided before the guard is removed rather than inherited by the phase that removes it.
Definition of done¶
plan_vhdwith aDynamicsubformat and a backing reference returns a plan whose bytes, laid into a buffer, are accepted byVhdFooter::parse,VhdDynamicHeader::parseandVhdParentInfo::parse, withdisk_type == 4, the parent name decoding to the input path, and the selected locator carryingdefect == None.- The negative control in 5e fails the locator validation when the data is deliberately misplaced, demonstrating the positive assertion is not vacuous.
plan_vhdwith no backing produces byte-identical output todevelopatf981374for the same options, proven by a committed test rather than by inspection.plan_vhdwithVhdSubformat::Fixedand a backing returnsCreateError::BackingFileUnsupported.- A 255-UTF-16-code-unit parent path succeeds and a 256-code-unit one is refused; a path containing a non-BMP character reaches that limit 2 code units at a time (127 such characters fit, 128 do not). The last two bytes of the parent name field are always zero, asserted directly rather than assumed.
shared::utf8_to_utf16andshared::utf16_to_utf8round-trip every case in the existingutf16_to_utf8test set.- The same parent path encodes to two different byte strings under
big_endian = trueandbig_endian = false, asserted by a test. VhdFooter::timestampis read from offset 24, asserted against a footer whose timestamp differs from its size fields.make test-rustpasses,make check-binary-sizespasses (no guest binary changes in this phase, so this is a regression check, not a budget question), andpre-commit run --all-filesis clean.fuzz_create_emittersbuilds and runs against the new path without tripping itsminimum_file_sizeor overlap oracles.docs/create.mdis unchanged, andcreate -f vpc -bstill fails with the same error a user sees today. Verified by hand against a binary built from this branch, not by a committed test:create -f vpc parent.vhd 16Msucceeds andcreate -f vpc -b parent.vhd -F vpc child.vhd 16MgivesError: "create failed: invalid option for target format", exit 1, with no child file written. There is no committed test for this and there cannot be one incrates/create: decision 8's guard lives in the guest binary, which that crate's harness cannot reach. Permanent coverage belongs with the phase that adds the Python integration tests, and its brief should say so.
This replaces a Definition-of-done item that was impossible.
The plan originally required that git diff develop...HEAD
touch no file under src/operations/ or src/vmm/. It cannot:
VhdCreateOpts is built by struct literal at seven sites, two
of which are src/operations/create/src/main.rs:310 and
src/fuzz/fuzz_targets/fuzz_create_emitters.rs:175, so adding
the parent-identity fields breaks compilation until they are
updated. The survey missed this by reading plan_vhd and not
its callers. What the criterion was protecting — that this phase
changes no user-visible behaviour — is preserved by the explicit
guard in decision 8 instead, and is the falsifiable thing to
check.
* #566 is open, accurate, and linked from the master plan's
Future work.
Back brief¶
Before implementation starts, the management session should confirm three things, because each is cheap to agree now and expensive to redo after 5d:
- Decision 2 — that the all-zero footer UUID stays as it is
in this phase and becomes an issue. This is the call most
likely to be disagreed with, and reversing it after 5d means
revisiting
plan_vhd's signature, the guest/host ABI, and every golden comparison intests/test_create.py. - Decision 1's fixed offset of 1536 for the locator data, and specifically that the BAT moves only for a differencing child. If the reviewer would rather the region sat after the BAT, that is a layout change touching 5d and 5e together.
- The intermediate state — that this phase deliberately
lands an emitter no user can invoke, with
create -f vpc -bstill refused until phase 7. If the operator would rather phases 5 and 7 landed as one pull request, the step plan needs to say so before 5d rather than after.