VHD and VHDX differencing output¶
Status: In progress¶
Prompt¶
Before responding to questions or discussion points in this document, explore the instar codebase thoroughly. Read relevant source files, understand existing patterns (VMM structure, guest operation layout, shared crate conventions, call table ABI, format parsing, test infrastructure), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead. Where a question touches on external concepts (QCOW2, VMDK, VHD/VHDX, LUKS, KVM, virtio, disk image formats), research as needed to give a confident answer. Flag any uncertainty explicitly rather than guessing.
All planning documents should go into docs/plans/.
Consult ARCHITECTURE.md for the overall system structure
(host VMM, KVM guest, call table, device emulation).
Consult AGENTS.md for build commands, project conventions,
code organisation, and the security model summary. Consult
docs/ for format-specific documentation (docs/qcow2/,
docs/raw/, etc.) and docs/commentary/ for architectural
decisions and design rationale.
This plan carries the mandatory push audit phase described in
PLAN-TEMPLATE.md, and the Merged column that phase needs. The
column is filled in as each phase lands, not reconstructed
afterwards.
Situation¶
The 2026 technical goal VHD/VMDK writers done has four leaves:
VHD fixed output (shipped), VHD/VHDX differencing output, VMDK
twoGbMaxExtent* output, and vmdk/vpc/vhdx preallocation. This
plan takes the differencing leaf. It is the only one of the three
outstanding leaves that needs no call-table change, which is why
it goes first: multi-extent VMDK output is blocked on
multi-output-device support in the call table, and preallocation
is blocked on a per-format BAT population pattern.
What exists today, measured on 2026-09-05 against the binary
built from d59cc40 and qemu-img 10.0.11:
instar create -f vpc -b parent.raw -F raw child.vhd 16Mfails with "create failed: invalid option for target format".plan_vhd(src/crates/create/src/lib.rs:767) andplan_vhdx(:919) both returnCreateError::BackingFileUnsupportedwhen a backing reference is present, each with a comment deferring the work as "too complex for phase 1" ofPLAN-create.md.src/crates/vhd/src/lib.rsknowsDISK_TYPE_DIFFERENCING = 4(:93) andVhdState::initaccepts it (:578), but nothing in the crate parses the dynamic header's parent fields or its eight parent locator entries.src/crates/vhdx/src/lib.rsfinds the parent locator metadata item and then discards it:parent_loc_offsetandfound_parent_locare assigned and immediatelylet _ = ...at:517-519. Only theHasParentfile-parameter bit survives, asVhdxMetadata::has_parent(:462), andVhdxState::initrejects any image that sets it (:842).- Reading a differencing VHD therefore returns the child's
allocated blocks and zeros everywhere else, with no diagnostic.
instar convert -O rawon thevhd-differencing.vhdfixture exits 0 and writes a 10 MiB raw image composed as if the parent did not exist.instar inforeports no parent.mapis the only op that refuses (src/operations/map/src/main.rs:459-462), andcheckrefuses only the VHDX case (:1555). CHANGELOG.md:1922claims VHD input support for "fixed, dynamic, differencing with backing chains". The chain half of that claim has never been true.
What the outside world does:
- qemu-img creates neither.
qemu-img create -f vpc -b base.vhd -F vpc child.vhd 16Mand the vhdx equivalent both fail with "Backing file not supported for file format 'vpc'" / "'vhdx'". qemu's vpc and vhdx drivers have no differencing write path at any shipped version. - qemu-img reads a differencing VHD the same way instar does:
qemu-img infoon the fixture reports a plain 10 MiB vpc image and never mentions a parent. So instar's silent read is qemu-parity, but both are silently wrong rather than right. - This makes differencing output an instar-only capability with
no qemu-img oracle for creation or composition, like the
vmdk/vhd/vhdx
resizeand vmdkrebasedivergences already recorded as note 8 indocs/format-coverage.md. Every other write path instar has shipped was validated against qemu-img. This one cannot be, so the plan has to establish an oracle before it writes anything. The absence is not total, and the plan should not overstate it: step 1b MEASURED that qemu-img 10.0.11 validates the VHD head footer's checksum and refuses a corrupted one ("Could not open: Incorrect header checksum"), so that one field is externally cross-checked even though qemu never resolves the parent. The dynamic header checksum, the tail footer copy's checksum and the whole locator table are not. - Debian 13 packages
libvhdi-utils(vhdiinfo,vhdimount) andpython3-libvhdifrom the libyal project, which does implement VHD and VHDX parent chains. Phase 1 accepted it as this plan's oracle; open question 2 carries the verdict and the two gaps that come with it -- there is novhdiexport, and libvhdi never parses the VHD parent locator table.
The fixtures are not usable as they stand.
instar-testdata/custom/format-coverage/vhd-differencing.vhd has
disk_type = 4 in its footer but its parent unique id, parent
unicode name and all eight parent locator entries are zero: it is
a type marker, not a differencing disk, and its companion
vhd-diff-base.vhd is not referenced by it. There is no VHDX
differencing fixture at all.
The generator is not missing, though — phase 2's survey found it
in the other repository from where the master plan looked for it.
scripts/create-vhd-testdata.sh in instar produces both
existing VHD fixtures, writing into
../instar-testdata/custom/format-coverage by default, and its
comment at :150-152 states the strategy outright: qemu-img
cannot create differencing VHDs, so it patches disk_type from 3
to 4 in a dynamic image and recomputes the checksums. The type
marker was deliberate, and the manifest entry
(tests/manifest.json:197-206) describes it honestly as "patched
from dynamic for type acceptance testing". Two tests depend on it
(tests/test_check_formats.py:1877, :1900), so phase 2
supplements rather than replaces it.
The house pattern this reveals — generator in instar, binaries in
instar-testdata, registration in instar's tests/manifest.json —
means phase 2 spans both repositories and lands as two pull
requests, and its Merged cell carries a record from each.
Related planned work lives in the testdata repository:
instar-testdata/docs/plans/PLAN-extra-coverage.md priority 7
proposes five adversarial parent-locator fixtures (absolute
/etc/passwd, ../../../etc/passwd, UNC, and eight mutually
disagreeing locators). That priority is unstarted, and it becomes
directly relevant the moment instar parses a locator table.
The host is further along than the rest of this picture
suggests, which phase 1's survey established and which matters
most to phase 11. discover_backing_chain
(src/vmm/src/main.rs:2501) is not qcow2-only: it walks a chain
with circular-reference detection, a depth limit and a path
allowlist, and it already carries a non-qcow2 special case in the
VMDK flat-descriptor short-circuit that resolves
parentFileNameHint. Its security knobs,
security.backing_path_allowlist and security.max_chain_depth,
are declared at src/vmm/src/config.rs:65 and :67. Composition
extends that function; it does not write one.
Step 1c narrowed the read-side defect, and the narrowing
matters to phase 4. The silent misread is VHD only. On a
differencing VHDX every read op already fails, because
VhdxState::init rejects a parent at the crate level before any
op-specific logic runs -- so convert, compare, dd, bench and
measure inherit a refusal none of them wrote. What they inherit
is a generic message rather than a diagnosis: instar convert -O
raw on a Hyper-V differencing VHDX gives "convert operation
failed", and instar compare given the same differencing VHDX as
both arguments reports "Content mismatch at offset 0!" -- a file
differing from itself. Phase 4's job is therefore two-sided:
turn the VHD silence into a typed refusal, and turn the VHDX
generic failures into the same typed refusal, rather than
assuming VHDX is already correct because it exits non-zero. Both
measured 2026-09-05 against the d59cc40 binary.
Two issues now record the read-side defects: #547 for the VHD
silent misread, and #548 for compare reporting a content
mismatch between a differencing VHDX and itself. Phase 4 closes
both.
Mission and problem statement¶
Make instar create -f vpc|vhdx -b PARENT -F FMT child produce a
differencing disk that an independent implementation resolves
against its parent, and stop instar reading differencing children
as though they had none.
In scope:
- Differencing VHD output:
disk_type = 4, parent unique id, parent timestamp, parent unicode name, and a parent locator table carrying the single entry open question 3 settles on, with both checksums correct and the BAT wholly unallocated. - Differencing VHDX output: the
HasParentfile-parameter bit, a populated parent locator metadata item carrying theparent_linkageGUID and the locator path entries. - Parent-locator parsing in
crates/vhdandcrates/vhdx, to the standard the rest of the format crates hold:no_std, panic-free, every offset and length from the image bounds-checked before use. - A defensible read-side answer for differencing children, so that no op silently composes the wrong image: a refusal first, and then real chain composition, so instar ends the plan able to read back what it writes.
- An external oracle, and fixtures generated by a script that lives in the testdata repository rather than by hand.
Out of scope, and deliberately left to their own work:
- Multi-extent VMDK output and vmdk/vpc/vhdx preallocation, the other two leaves of the same goal.
resizeof a differencing image, whichdocs/resize.md:215-216already defers pending the parent-locator update path this plan builds.- VHDX log replay, which remains rejected as it is today.
Open questions¶
- Read side: refuse, or compose? RESOLVED 2026-09-05 by
the operator: both, in that order. The plan refuses first
and composes last. Phase 4 turns the silent parent-ignoring
read into a typed refusal, which closes the wrong-data hole
immediately and holds while the emitters land; phases 11 to 16
then implement real chain composition for VHD and VHDX, the way
instar already composes qcow2 backing chains -- the host
attaches each chain member as its own virtio device and the
guest takes an
input_device_count. The refusal is therefore an interim state inside this plan rather than its endpoint, and instar finishes the plan able to read back everything it writes. Phase 4 is still worth its own phase: it is the only part of the read-side answer that has to be true before the emitters ship, and it is a defect fix rather than a feature. - Is libvhdi a sufficient oracle? RESOLVED 2026-09-05 by
step 1a: yes, with one named gap. libvhdi 20240509
(Debian
libvhdi-utils,libvhdi1andpython3-libvhdi, all20240509-2+b1) resolved Hyper-V produced differencing chains for both formats --fat-differential.vhd,ntfs-differential.vhdand their.vhdxcounterparts from thelog2timeline/dfvfstest corpus, creator applicationwin, images nothing in this project wrote -- reporting a parent identifier equal to the parent's own identifier and the correct parent filename. Its compositions matched the content the chains were built to represent byte for byte:cmpexit 0 for our generated VHD and VHDX chains against their intended raw images, with parent-only and child-only controls differing, and a sector-provenance analysis of the composed Hyper-V VHD chain finding every composed sector attributable to exactly one file. Two corrections to how the oracle is driven, both from step 1a:vhdiexportdoes not exist -- libvhdi shipsvhdiinfoandvhdimount, and Debian builds the latter without FUSE -- so composition runs through thepython3-libvhdibinding with an explicitset_parent()rather than a CLI export; and libvhdi never parses the VHD parent locator table, sincelibvhdi_parent_locator*is reached only from the VHDX metadata path and VHD resolution uses the parent unicode name field alone. That table therefore has no content oracle at all, which is the single biggest gap here: phase 5 must not assume otherwise and phase 8's assertions on it are structural only. Step 1b then added one field back that libvhdi does not cover: qemu-img 10.0.11 does validate the VHD head footer's checksum and refuses a corrupted image with "Could not open: Incorrect header checksum", where libvhdi opens it and resolves the parent regardless. The dynamic header checksum has no oracle in either tool. The full verdict, tool versions, command lines and the list of fields nothing external checks are in the phase 1 plan's Result -- step 1a and What has no oracle sections. - Which locator entries do we emit? RESOLVED 2026-09-05 by
step 1b: one entry, in slot 1, whose platform code
describes the string the user actually gave us --
W2ruwhen the typed backing path is relative,W2kuwhen it is absolute -- with slots 2 through 8 left zero. This is a deliberate divergence from Hyper-V. MEASURED in both corpus VHDs, Hyper-V writes exactly two populated entries:W2kucarrying the absolute path in slot 1 at offset 1088 andW2rucarrying the child-relative path in slot 2 at 1112, with slots 3 to 8 zero. What decides against copying that is a host-side fact:run_create_nonrawresolves the typed backing path against the output's directory for opening the file but sends the guesttyped_backing.as_bytes()unchanged (src/vmm/src/main.rs:16913-16963). The guest therefore has one string, and writing two entries would mean fabricating the other --.\<basename>is simply wrong whenever parent and child are in different directories, and a fabricated locator is worse than an absent one. Passing a second, host-resolved path would need a new call table field, and this plan's premise that differencing output needs no ABI change (see phase 7's rationale below) is worth more than a cosmetic match to Hyper-V. The oracle cannot arbitrate: libvhdi ignores the VHD locator table entirely, so it cannot tell us whether one entry is enough for other readers, and no Windows host is in this plan's reach to ask. The falsifier is a Windows or Hyper-V rejection of a single-entry child; adding a second entry later is additive to the emitter and to nothing else. The VHDX side follows the same rule for the same reason:parent_linkageplus exactly one ofrelative_pathorabsolute_win32_path, and nevervolume_path(which needs a Windows volume GUID) orparent_linkage2. Reopened in part, 2026-09-15, by the review of the phase 5 pull request (#568). This answer settled which entries to emit; it did not consider that the one path string the guest is handed is a POSIX path, whileW2kuandW2ruare defined as Windows paths and measured Hyper-V output writes.\fat-parent.vhdandC:\Projects\.... The emitter as built writes the typed path verbatim under those codes, so a Windows reader could resolve it drive-relative. Issue #570 carries the options and the evidence, and must be settled before phase 7 removes the guard that currently stops any user reaching the emitter. The reasoning above — one entry, never a fabricated second — is unaffected, as is the parent unicode name field, which is the only one libvhdi and qemu read. - Does an instar-only capability need an opt-in flag?
RESOLVED 2026-09-05: no flag. instar already performs
vmdk/vhd/vhdx
resizeand vmdkrebasewhere qemu-img refuses on every shipped version, unflagged, and records them as note 8 indocs/format-coverage.md. Differencing output is a recorded divergence in the same way rather than a gated one. - Parent path resolution and its security posture.
RESOLVED 2026-09-05 by phase 1's survey, and the resolution
is a description of code that already exists rather than a
new rule to write.
discover_backing_chain(src/vmm/src/main.rs:2501) is not qcow2-only: it already performs circular-reference detection, depth limiting and allowlist checking for every chain the host walks, governed bysecurity.backing_path_allowlistandsecurity.max_chain_depth(src/vmm/src/config.rs:65and:67), and it already carries a non-qcow2 special case in the VMDK flat-descriptor short-circuit. Differencing parents go through that same function -- phase 11 extends it rather than writing one -- so a path read out of an image is resolved relative to the child's directory and checked against the allowlist before anything opens it, and nothing in the guest ever opens a path read out of an image. Phase 3 is still required to be bounds-check-clean againstPLAN-extra-coveragepriority 7 inputs; that requirement is unchanged by this resolution. - Do we pull in the adversarial fixtures now? RESOLVED
2026-09-05: yes, in phase 2, taking priority 7 of
instar-testdata/docs/plans/PLAN-extra-coverage.md(the absolute/etc/passwd,../../../etc/passwd, UNC and eight-mutually-disagreeing-locator cases). Phase 3 is the code that needs them, and generating the happy-path and adversarial sets from one script is cheaper than two passes. - Must a differencing child's parent share its format?
RESOLVED 2026-09-05 by decision 5 of the phase 1 plan:
yes, with a typed error otherwise. Hyper-V requires VHD
parents for VHD children and VHDX for VHDX, and emitting a
chain no implementation can resolve is worse than a typed
refusal. instar's
create -bpath currently accepts any detectable parent format; phase 7 wires the error.
Execution¶
Each phase gets its own detailed plan file before implementation
begins; this table is the tracking source of truth. The Merged
column records what put each phase on develop -- the merge
commit of its pull request, or a first..last range for a phase
that landed directly -- because the push-audit phase runs
PUSH-AUDIT.md over the union of those ranges, and git diff develop...HEAD is empty once the
phases have landed. A phase that lands in instar-testdata
records instar-testdata <sha> (#pr) and is audited there.
| Phase | Plan | Status | Merged |
|---|---|---|---|
| 1. Semantics pin, oracle selection, and the doc correction | PLAN-differencing-phase-01-pin.md | Complete | 8b81a0f (#549) |
| 2. Real differencing fixtures, happy-path and adversarial (instar + instar-testdata) | PLAN-differencing-phase-02-fixtures.md | Complete | instar-testdata 77f5f589f0 + 623a30866f + 3eed61bf75 (direct to main); instar 1a677c77 (#552) |
3. Parent-locator parsing in crates/vhd and crates/vhdx |
PLAN-differencing-phase-03-parse.md | Complete | 42e879f (#558) |
| 4. Read-side policy: close the silent parent-ignoring read | PLAN-differencing-phase-04-read-policy.md | Complete | f981374 (#563) |
5. plan_vhd differencing emitter |
PLAN-differencing-phase-05-vhd-emitter.md | Complete | 9a80776 (#568) |
6. plan_vhdx differencing emitter |
PLAN-differencing-phase-06-vhdx-emitter.md | Complete | 882d098 (#577) |
| 7. Guest create op and host CLI wiring | PLAN-differencing-phase-07-guest-host.md | Complete | 99d7d24 (#581) |
| 8. Rust unit tests and Python integration tests | PLAN-differencing-phase-08-tests.md | Complete | c416abd (#588) |
| 9. Coverage fuzzing of the locator parsers | PLAN-differencing-phase-09-fuzz.md | Planned | |
| 10. Documentation | PLAN-differencing-phase-10-docs.md | Not started | |
11. Composition: host chain discovery, device attachment, info --chain |
PLAN-differencing-phase-11-chain-host.md | Not started | |
| 12. Composition: guest VHD sector-bitmap read path | PLAN-differencing-phase-12-vhd-compose.md | Not started | |
| 13. Composition: guest VHDX sector-bitmap read path | PLAN-differencing-phase-13-vhdx-compose.md | Not started | |
| 14. Composition: per-op rollout, replacing phase 4's refusals | PLAN-differencing-phase-14-op-rollout.md | Not started | |
| 15. Composition: integration tests and fuzz | PLAN-differencing-phase-15-compose-tests.md | Not started | |
| 16. Composition: documentation | PLAN-differencing-phase-16-compose-docs.md | Not started | |
17. Push audit: PUSH-AUDIT.md over every phase above |
PLAN-differencing-phase-17-push-audit.md | Not started |
Sequencing rationale¶
Phase 1 comes first because it is the only phase that can
invalidate the rest: if no oracle exists, the shape of phases 8
and 9 changes and the operator should know before any emitter is
written. It also answers open questions 1, 2, 3 and 7, and its
first deliverable has already landed -- commit a93615d on this
branch corrected docs/create.md, which claimed vpc and vhdx
honoured backing_file when both planners reject it.
Phase 2 precedes phase 3 because a parser with no real input is a parser with no test. Phase 3 precedes both emitters because parse-then-emit lets each emitter be checked by instar's own reader before the external oracle is involved, which is how every other format crate in this repository was built.
Phase 4 sits before the emitters deliberately. It is the phase that fixes an existing defect rather than adding a feature, and putting it first means the tree is never in a state where instar writes differencing disks while still silently misreading them.
Phases 5 and 6 are independent of each other and could be parallelised; VHD goes first because its parent locator table is the simpler structure and the lessons carry into VHDX.
Phase 6 carries a trap step 1b found by reading the tree, and it
is worth stating here because it changes what phase 8 can test.
The VHDX parent_linkage key is the parent's DataWriteGuid,
settled by measurement against Hyper-V bytes and confirmed
against SPEC(VHDX) 2.6.2.6.3 and libvhdi's source. But
vhdx::build_header derives the DataWriteGuid from the sequence
number alone (src/crates/vhdx/src/lib.rs:2201, the two GUID
writes at :2206-2215), and every instar VHDX writer passes
sequence numbers 1 and 2 -- plan_vhdx at
src/crates/create/src/lib.rs:1161 and :1163 and the convert
op at src/operations/convert/src/main.rs:4469 and :4492. Every VHDX
instar has ever written therefore shares one active-header
DataWriteGuid, which makes libvhdi's parent-identity check
vacuous for instar-written chains: any instar parent
satisfies any instar child. plan_vhd has the same hole for the
same reason -- it writes UUID_ZERO as the footer unique id of
every image it creates (src/crates/create/src/lib.rs:958,
:1021 and :1072), and that field is exactly what a
differencing child copies into its dynamic header at offset 552.
Phase 5 did not change that: the child's copy comes from
opts.parent_unique_id (:977), which the create operation
still fills with zeros until phase 7. Phase 6 should
confirm the VHDX half against a real instar-produced image (the
claim is read from code, not measured on output) and consider
giving created images a real DataWriteGuid; phase 8's
negative identity test must be built against a third-party
parent either way, because an instar-created parent cannot fail
it. Discharged by phase 7, which built exactly that test:
tests/test_create.py round-trips both formats against
third-party fixtures and asserts the fixture identity is non-zero
before comparing, so the comparison is not zeros against zeros.
Phase 8 carried one debt from phase 5, now discharged and
recorded here because the phase 5 plan is not where phase 8's
planner will look. create -f vpc -b is still refused at the guest
by an explicit guard that phase 7 removes, and that refusal had no
committed test: the guard is in the guest binary and
crates/create's harness cannot reach it, so phase 5 verified it
by hand against a built binary. Phase 6's review round added
test_create_vhd_and_vhdx_reject_backing to tests/test_create.py,
covering vpc and vhdx together, so phase 8's brief no longer needs
to and phase 7 gets a failing test if it removes either guard too
early.
Phases 8 and 15 both build fixtures with partially populated
blocks, and both must account for a libvhdi defect step 1a found
and did not fix:
libvhdi_block_descriptor_read_sector_bitmap_data decodes the
VHD per-block sector bitmap with an unmasked shift, so once any
higher bit in a bitmap byte is set, every later sector covered by
that byte reads as present in the child. It was measured on
Hyper-V's own fat-differential.vhd and reproduced
deterministically with a probe that predicted seven wrong sectors
and got exactly those seven. VHD fixtures must therefore keep
parent-owned and child-owned sectors out of the same bitmap byte,
or carry expected output that accounts for the bug; discovering
it in phase 15 instead would look exactly like an instar bug. The
VHDX branch of the same function is correct, and instar create
output is unaffected because a freshly created child has a wholly
unallocated BAT and no sector bitmaps at all.
Phases 11 to 16 are the composition work, and they come last because they are the only part that can be built on everything else: composition needs the locator parsing from phase 3 to find a parent, the emitters from phases 5 and 6 to generate chains to read, and the fixtures from phase 2 to read chains instar did not write. Phase 14 replaces phase 4's refusal op by op, so each op moves from "refuses, correctly" to "composes, correctly" and never passes back through "silently wrong".
Composition is a plan's worth of work on its own -- it is the
half of this plan that touches the guest read path, where the
output half touches only the writer -- so it is decomposed here
rather than left as one phase to be split later, the way
PLAN-resize-followup-01 split its own:
- Phase 11, host side. Resolving a locator path to a real
parent, applying the same resolution and depth rules qcow2
backing chains get, and attaching each chain member as its own
virtio device. Ends with
info --chainwalking a VHD or VHDX chain, which is the cheapest possible proof the host half works and needs no guest change at all. - Phases 12 and 13, guest side. The two formats are not the same problem and each is a self-contained read-path change, so they are separate phases that can be planned, reviewed and reverted independently. VHD first, for the same reason its emitter goes first.
- Phase 14, rollout. Turning the refusals into composition
across
convert,compare,dd,bench,map,measureandcheck. Mechanical once 11 to 13 land, but it is the phase that changes what users see, and it wants its own review rather than being tacked onto a guest phase. - Phase 15, tests and fuzz. Cross-validation against the
phase 1 oracle for chains instar wrote and chains it did not,
plus coverage fuzzing of the compose path. Its harness drives
the
python3-libvhdibinding with an explicitset_parent()rather than a CLI export, and its VHD fixtures are subject to the sector-bitmap caveat above. Phase 9 fuzzes the locator parsers; composing a chain is new surface, and a malicious child pointing at a well-formed parent is a different input space from a malformed locator table. - Phase 16, documentation. Separate because phase 10 will
have documented a refusal that phase 14 removes: at minimum
docs/chain-discovery.md,docs/chain-config.mdand the read-side rows and divergence notes ofdocs/format-coverage.md.
Two specifics phases 12 and 13 must confront, both already visible in the crates:
- VHD differencing selects between child and parent at sector
granularity, not block granularity: the per-block sector bitmap
says which sectors of an allocated block are the child's.
VhdStatetoday computes the bitmap's size only to skip past it (src/crates/vhd/src/lib.rs:1432-1441,:1534) and never reads a bit, which is right for a dynamic disk and wrong for a differencing one. - VHDX carries the equivalent in its sector-bitmap BAT entries,
which the current walker deliberately skips
(
src/crates/vhdx/src/lib.rs:1820), and it treatsPAYLOAD_BLOCK_PARTIALLY_PRESENTas fully present data (:1408,:1438,:1487) -- a v1 simplification that is only safe while differencing images are rejected outright.
Whether map's per-extent depth field participates is a
scoping call for phase 14's plan: PLAN-map.md deferred
backing-chain depth composition for qcow2 as well, so VHD/VHDX
depth may reasonably follow qcow2's rather than lead it.
Phase 7 is the smallest of the implementation phases: the host
already attaches the backing file as input device 0 when -b is
given (run_create_nonraw, src/vmm/src/main.rs:16816), so the
guest can read the parent's footer for its unique id and
timestamp without any new call-table primitive. That is the fact
that makes this plan tractable, and phase 1 confirmed it still
holds: step 1b read the same function and found that it opens the
host-resolved parent but embeds typed_backing.as_bytes()
verbatim in what the guest receives (:16914-16964). That single
typed string is also what forces the single-locator-entry answer
in open question 3.
Phase 7 also inherits two guards it must not remove blindly.
Phase 5 added an explicit backing refusal to the create
operation's ImageFormat::Vhd arm and phase 6 adds the matching
one for ImageFormat::Vhdx, because each emitter became able to
write a child whose parent identity is all zeros before the guest
could read a real one. Both guards are covered by
test_create_vhd_and_vhdx_reject_backing in tests/test_create.py,
added during phase 6's review round: it asserts each format refuses
-b with "invalid option for target format" and writes no child
file. So phase 7 will get a failing test if it removes a guard too
early -- which was not true when this paragraph was first written.
Issue #570 (POSIX paths emitted under Windows-only platform codes
and the absolute_win32_path key) must be settled before either
guard comes off, since removing them is what first exposes that
choice to a user. Phase 6's review raised the same point from the
planner's side: neither plan_vhd nor plan_vhdx validates
backing.format, so a direct crate caller can ask for a
differencing VHDX naming a qcow2 parent and get a well-formed image
no consumer can compose. That is not an open choice -- open
question 7 above resolved it on 2026-09-05 ("yes, with a typed
error otherwise"), and phase 7 wires that error. Phase 7's plan
also improves on the framing: config.backing_format is only
populated when the user passes -F
(src/vmm/src/main.rs:16975-16977), while the guest can detect the
parent's real format from its header, so the check is on the
detected format rather than on the hint.
Constraints that apply throughout¶
- Guest binaries stay under the 768KB per-operation cap
(
make check-binary-sizes). Measured during phase 7's survey, the create op uses 65,224 bytes of it -- 8.5% -- and already depends on both thevhdandvhdxcrates, so this is a regression check rather than a budget question. - The format crates are
no_stdand panic-free. Every offset and length taken from an image is bounds-checked before use; the existing qcow2 and vmdk crates are the pattern. - Parent locator paths are untrusted. Nothing in the guest ever opens one, and the host applies the same resolution rule it applies to qcow2 backing references.
- Every phase that changes user-visible behaviour updates the documentation that describes it in the same pull request. Phase 10 exists for the cross-cutting pages, not as a licence to leave the per-phase pages stale.
Agent guidance¶
The canonical guidance -- execution model, planning effort, step
tables, model roster, review checklist -- is in
PLAN-TEMPLATE.md, and this plan follows it rather than
restating it. What is specific to this plan:
- Execution model. All implementation work is done by sub-agents; the management session plans, reviews the actual files rather than the sub-agent's summary, and commits.
- Planning effort. Phases 1, 3, 4, 5 and 6 are high effort: they turn on format-spec interpretation, an architectural decision about read behaviour, or emitting structures no reference implementation in reach will double-check for us. Phases 2, 8, 9 and 10 can be planned at medium effort with good briefs. Phase 7 is high effort only because it touches the guest/host boundary; the change itself is small.
- Model choice. Skew to opus for the emitters and the parse layer. Phase 5 and 6 briefs must name the exact byte offsets and the checksum algorithm, because a plausible-looking wrong offset in a format nobody else validates is precisely the failure this plan is exposed to.
- A standing warning from this repository's history. Agents assert plausible-but-wrong format and tool capabilities. Require cite-or-measure for every claim about what libvhdi, Hyper-V or qemu accepts, and re-measure a sample in the management session before it is written into a brief.
Administration and logistics¶
Success criteria¶
We will know this plan has been implemented because:
instar create -f vpc -b PARENT -F vpc child.vhd SIZEand the vhdx equivalent produce images the phase 1 oracle resolves against their parent, with content matching what instar intended.- No instar op silently composes a differencing image as though
it had no parent, at any commit in the plan: phase 4's refusal
and then phase 11's composition are applied uniformly across
info,check,convert,compare,dd,bench,mapandmeasure. Phase 4's survey corrected two assumptions here:mapalready refuses (commiteb6e23f, 2026-06-03) and is the precedent phase 4 generalises rather than outstanding work, as doesresize; andddis not an operation --run_dd(src/vmm/src/main.rs:13958) callsexecute_convert, so it shares convert's guest binary and inherits its behaviour. instar convert -O rawon a differencing child produces the same bytes as the phase 1 oracle's composition of the same chain -- driven through thepython3-libvhdibinding, since there is novhdiexport-- for chains instar wrote and for chains it did not, andinstar info --chainwalks a VHD or VHDX chain the way it walks a qcow2 one.crates/vhdandcrates/vhdxparse parent locator structures and are clean under the new fuzz targets, including the adversarial fixtures from phase 2.make instarbuilds,make lintis clean,make check-binary-sizespasses,make test-rustandmake test-integrationpass, andpre-commit run --all-filespasses.docs/create.md,docs/format-coverage.md(both the output side table and the divergence notes),docs/quirks.md,docs/resize.md,docs/guest-architecture.md,docs/chain-discovery.md,docs/chain-config.md,ARCHITECTURE.mdandCHANGELOG.mddescribe what shipped, and the false "differencing with backing chains" input claim atCHANGELOG.md:1922is reconciled by a current statement of what is actually supported.- The push audit in phase 17 has run
PUSH-AUDIT.mdover the union of the merged ranges, and its findings are resolved or declined in writing.
Documentation index maintenance¶
docs/plans/index.md carries a row for this plan in the Master
plans table, and docs/plans/order.yml carries an entry for the
master plan only. Phase files are linked from the index row and
from the Execution table above as they are written, and are not
added to order.yml. When every phase is complete the index
status becomes Complete.
Future work¶
resizeof a differencing image, which needs the parent-locator update path this plan builds (docs/resize.md:215).rebasefor differencing VHD/VHDX -- repointing a child at a new parent -- whichPLAN-rebase-commit.md:236deferred for want of exactly this parse and emit layer.- The other two leaves of the same 2026 goal: multi-extent VMDK output, blocked on multi-output-device support in the call table, and vmdk/vpc/vhdx preallocation, blocked on a per-format BAT population pattern.
- Giving created VHD and VHDX images a real identity (#566).
plan_vhdwrites an all-zero footer unique id andvhdx::build_headerderives the DataWriteGuid from the sequence number, so every image instar creates shares one identity and the parent-identity check is vacuous for chains instar wrote end to end. Deferred by decision 2 of the phase 5 plan: it needs a host-side entropy source passed through the call table, which is the one ABI change this plan is built to avoid. The negative identity test therefore uses a third-party parent; phase 7 built it, so this is a note on the constraint rather than outstanding work. - Differencing-aware
check, once a chain can be resolved: todaycheckrefuses VHDX differencing and validates a VHD differencing child as if it were dynamic.
Bugs fixed during this work¶
docs/create.mdclaimed vpc and vhdx honouredbacking_fileandbacking_fmt, and marked both "Yes" for backing support, where both planners reject a backing reference outright. Fixed on this branch ina93615d, ahead of the phase 1 plan file.instar comparereports "Content mismatch at offset 0!" for a differencing VHDX compared against itself, becauseVhdxState::init's refusal happens in the guest rather than on the host andcomparereads the resulting absence of content as a difference. A format refused on the host, such as bochs or qed, refuses cleanly by name. Filed as #548; phase 4 fixes it alongside #547.- The silent parent-ignoring read of differencing VHDs is a live
correctness defect, not merely a missing feature:
convertproduces a wrong image and exits 0. Filed by step 1d as issue #547, "VHD differencing (disk type 4): convert -O raw silently composes wrong data, exits 0", labelledbug; phase 4 fixes it. There were no open issues on this surface when the plan was written. instar-testdata/custom/format-coverage/vhd-differencing.vhdis adisk_type = 4marker with an empty parent name and eight zeroed locator entries, so it does not exercise what its name implies. Phase 2 replaces or supplements it and records what the old fixture was actually testing.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.