Phase 1 — semantics pin, oracle selection, and the doc correction¶
Phase 1 of PLAN-differencing.md.
Goal¶
Establish that this plan can be validated at all, and decide the things later phases would otherwise each decide differently.
Every write path instar has shipped was cross-validated against qemu-img. This one cannot be: qemu-img creates neither differencing VHD nor differencing VHDX, and reads both as though the parent did not exist. Before an emitter is written, the plan needs an external implementation that resolves a differencing chain, or an honest statement that none is available and what we are doing instead. That is this phase.
No source file under src/ changes in this phase.
Planning effort¶
High. The phase turns on format-spec interpretation and on a go/no-go judgement that the rest of the plan depends on.
Review effort¶
High for step 1a's oracle verdict and step 1b's structure pin -- the management session re-runs a sample of the measurements rather than accepting the sub-agent's transcript. This repository has a documented history of agents asserting plausible-but-wrong qemu and format capabilities, and a wrong offset in a format nobody else validates is exactly the error this plan is exposed to.
Medium for the rest.
Scope¶
In scope:
- Prove or disprove an external oracle for differencing VHD and VHDX chains, with recorded evidence.
- Pin the on-disk structures this plan will emit -- VHD dynamic header parent fields and locator table, VHDX parent locator metadata item -- against the specs and against real bytes.
- Pin qemu-img's and instar's current behaviour in
docs/quirks.md. - File the GitHub issue for the silent parent-ignoring read.
- Settle the master plan's remaining open questions.
- The
docs/create.mdcorrection, which has already landed asa93615d.
Out of scope:
- Any change under
src/. The silent-read defect is filed here and fixed in phase 4; the temptation to fix it while it is in front of you is the thing this line exists to resist. - Fixtures in
instar-testdata. Phase 1 builds a throwaway chain in the scratchpad to test the oracle with; phase 2 owns the maintained generator. - Documenting the structures in
docs/format-internals.md. That page describes what instar implements, and in this phase instar implements none of it. Phase 10 writes it.
What the survey found¶
The master plan's Situation section was written on 2026-09-05 and
re-verified line by line while planning this phase. Every claim in
it holds, with the file and line references below confirmed
against the tree at d59cc40:
src/crates/create/src/lib.rs:767and:919reject a backing reference for vpc and vhdx respectively, each with a comment deferring the work as "too complex for phase 1" ofPLAN-create.md.src/crates/vhd/src/lib.rs:93definesDISK_TYPE_DIFFERENCING;:578accepts it intoVhdState;:664-667compute the per-block sector bitmap's size and:766skips past it to the payload. No bit of that bitmap is ever read.src/crates/vhdx/src/lib.rs:462deriveshas_parentfrom the file-parameter flags,:517-519discards the parent locator offset it just found,:842rejects differencing images, and:654skips the sector-bitmap BAT entries.- The fixture
instar-testdata/custom/format-coverage/vhd-differencing.vhdhasdisk_type = 4at footer offset 60 and zeroes in its parent unique id, parent unicode name (offset 576) and all eight locator entries (offset 1088). It is a type marker.
The survey did turn up one thing the master plan does not say,
and it is good news for phase 11: the host already has generic
chain machinery, and it is not qcow2-only.
discover_backing_chain (src/vmm/src/main.rs:2416) walks a
chain with circular-reference detection, a depth limit and a path
allowlist, and it already contains a non-qcow2 special case --
the VMDK flat-descriptor short-circuit that resolves
parentFileNameHint. The security knobs it uses,
security.backing_path_allowlist and security.max_chain_depth,
exist in src/vmm/src/config.rs:65 and :67. Phase 11 extends
this function rather than writing one, and open question 5's
resolution rule is a description of what this code already does.
This finding has been added to the master plan's Situation section as part of the planning commit, so the next reader does not have to rediscover it.
Decisions¶
- The oracle is libvhdi, subject to a written go/no-go.
Debian 13 packages
libvhdi-utils(vhdiinfo) andpython3-libvhdifrom libyal, which implements VHD and VHDX parent chains. (Corrected after step 1a: this decision originally saidvhdiexport, which does not exist. Debian'svhdimountis built without FUSE and refuses to mount, so thepyvhdibinding driven with an explicitset_parent()is the only composition path on this host.) It is accepted as this plan's oracle only if it passes both halves of step 1a: it resolves a chain instar did not write and reports the parent, and its composed export of a chain matches the content that chain was built to represent, byte for byte. Failing that, the fallbacks in order are a third-party-produced reference image used as ground truth, then structural-only assertions with the plan saying plainly that it has no content oracle. - Phase 1's chain generator is disposable. It lives in the
scratchpad and its source is pasted into this plan's appendix
when step 1a reports. Phase 2 lifts it into a maintained
generator in
instar-testdata. Blocking the go/no-go on a cross-repository, LFS-backed fixture review buys nothing. - A generator we wrote cannot be the only input to the
oracle test. If libvhdi accepts our chain and we later emit
the same misreading from
plan_vhd, the oracle will have validated nothing. Step 1a must obtain at least one differencing image produced by something other than us -- libyal's own test corpus, or a Hyper-V-produced sample -- or record that it could not, and downgrade the claim to "the oracle validates resolution, not conformance". - The structure pin lives in this plan, not in
docs/.docs/format-internals.mddocuments what instar implements. Only the qemu-vs-instar divergences, which are true today, go todocs/quirks.mdnow. - A differencing child must have a parent of its own format (master plan open question 7, resolved yes). Hyper-V requires it, and emitting a chain no implementation can resolve is worse than a typed refusal. Phase 7 wires the error.
- Phase 1 writes no code. The silent-read defect gets an issue number here and a fix in phase 4.
- This phase runs on the
vhd-differencingbranch, not a fresh phase branch. The master plan is not yet ondevelopand phase 1's first deliverable is already on this branch, so a branch offdevelopwould not contain the plan it implements. Phases 2 onward take their own branches offdeveloponce this lands. TheMergedcell for phase 1 therefore records the merge commit of the pull request that carries the master plan and this phase together.
The decision most likely to be argued with is 2. Putting the generator in the scratchpad means the evidence for the go/no-go is reproducible only from this plan's appendix, not from a checked-in script. The alternative -- open the testdata pull request first -- makes the go/no-go wait on a review in another repository, and if the answer is no-go, the fixtures were the wrong thing to have built.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1a | high | opus | worktree | Establish the oracle. Install libvhdi-utils and record vhdiinfo -V. Write a throwaway Python generator in the session scratchpad (NOT in any repository) that produces (i) a dynamic VHD base plus a differencing child referencing it, and (ii) a dynamic VHDX base plus a differencing child, each small (16 MiB) with known content in known sectors, some sectors present in the child and some only in the parent. The VHD child needs disk_type = 4 at footer offset 60, the parent unique id at absolute offset 552, parent timestamp at 568, parent unicode name (UTF-16BE) at 576, and locator entries from 1088; both footer and dynamic-header checksums are ones-complement sums over the structure with the checksum field zeroed. Then: run vhdiinfo and vhdiexport on each child and record verbatim output; compare the exported composition against the content you intended, byte for byte, with cmp. Separately, obtain at least one differencing image produced by something that is not this script -- try libyal's published test corpus first -- and run the same commands on it; if you cannot obtain one, say so explicitly rather than working around it. Report a go/no-go against decision 1's criterion, the tool version, every command line, and the generator source for pasting into the plan appendix. Do not modify any repository file. |
| 1b | high | opus | none | Pin the structures, against the specs and against real bytes. For VHD: the dynamic header's parent fields and the eight 24-byte parent locator entries (platform code, data space, data length, reserved, data offset), which platform codes Hyper-V writes, how W2ru relative paths are encoded, and the checksum algorithm. For VHDX: the parent locator metadata item -- its header, the parent_linkage GUID's meaning, and the key/value entry encoding -- plus which file-parameter bits must be set. Verify every offset you state against the images step 1a produced (xxd output in the report) and against src/crates/vhd/src/lib.rs / src/crates/vhdx/src/lib.rs where they already parse the surrounding structure. Cite a spec section or a measurement for every claim; where the spec is ambiguous -- parent_linkage is the likely one -- say so and state the interpretation phase 6 should implement. Output is a section appended to this plan, not a docs change. |
| 1c | medium | sonnet | none | Write the docs/quirks.md section recording current behaviour, following the shape of the existing "QED read-refusal as policy" section (:4038). Content, all of it measured on 2026-09-05 against qemu-img 10.0.11 and instar built from d59cc40, and to be re-run and quoted verbatim rather than copied from this plan: qemu-img create -f vpc -b base.vhd -F vpc child.vhd 16M fails with "Backing file not supported for file format 'vpc'" and the vhdx equivalent likewise; qemu-img info on a differencing child reports a plain image and never mentions a parent; instar convert -O raw on the same child exits 0 and writes an image composed without the parent; instar map refuses (src/operations/map/src/main.rs:459-462) and instar check refuses only the VHDX case (:1555). State plainly that instar's read is qemu-parity and that both are wrong, and link the issue from step 1d. |
| 1d | low | sonnet | none | File one GitHub issue against shakenfist/instar for the silent parent-ignoring read: title names convert reading a differencing VHD as if it had no parent and exiting 0, body carries the reproduction against instar-testdata/custom/format-coverage/vhd-differencing.vhd (noting that fixture's own limitation), the qemu-parity observation, and a pointer to phase 4 of this plan as the fix. Label bug. Do not fix it. Report the issue number. |
| 1e | medium | opus | none | Settle the master plan's open questions using the evidence from 1a and 1b. Rewrite questions 2 through 7 in docs/plans/PLAN-differencing.md as RESOLVED with the answer, the evidence, and the date, in the style question 1 already uses. Question 2 takes step 1a's verdict; question 3 takes whichever locator entries the oracle actually required; question 4 is resolved "no flag", consistent with the unflagged vmdk/vhd/vhdx resize divergence; question 5 is resolved as a description of discover_backing_chain's existing rules, citing src/vmm/src/main.rs:2416 and the two config keys; question 6 is resolved yes; question 7 is resolved yes per decision 5. If any answer contradicts a phase description later in the table, fix that description too and say so in the commit. |
| 1f | low | sonnet | none | Close the phase. Set phase 1's row to Complete in the master plan Execution table and the phase list in docs/plans/index.md, add the issue number from 1d to the master plan's Bugs fixed during this work, run pre-commit run --all-files, and confirm git diff --name-only develop...HEAD -- src/ is empty. Present the commits. |
Result — step 1a, the oracle verdict¶
GO. libvhdi is this plan's oracle.
Run on 2026-09-05 by the step 1a sub-agent and sampled again in
the management session, per this phase's review effort. The
management session independently re-ran the third-party
vhdiinfo, both compositions with their controls, and the two
qemu-img reads; all agreed.
Tool version and how it was installed¶
libvhdi-utils, libvhdi1 and python3-libvhdi, all
20240509-2+b1 from deb.debian.org/debian trixie/main amd64.
This host has no passwordless sudo, so the packages were fetched
with apt-get download and extracted with dpkg-deb -x into a
scratch prefix rather than installed. Same binaries, no host
mutation. Phase 15 will want them installed properly, or the same
prefix trick in the test harness.
Two corrections to the phase's assumptions¶
vhdiexportdoes not exist. libvhdi shipsvhdiinfoandvhdimount, and Debian buildsvhdimountwithout FUSE ("No sub system to mount VHDI format."). Composition therefore goes through thepython3-libvhdibinding with an explicitfile.set_parent(). Decision 1 has been corrected. Phase 15 plans a Python harness, not a CLI export.- libvhdi never parses the VHD parent locator table.
libvhdi_parent_locator*is reached only from the VHDX metadata path; VHD resolution uses theparent_unicode_namefield alone. So the oracle cannot validate instar's VHD locator table, and phase 8's assertions on it are structural only. This is the single biggest gap in the oracle and it is recorded here so phase 5 does not assume otherwise.
A corpus we did not produce, for both formats¶
Decision 3's requirement was met. libyal's own test data carries
no differencing images, but log2timeline/dfvfs's test_data/
carries Hyper-V produced differencing chains for both formats
(creator application win, creator version 0xa0000, absolute
locators under C:\Projects\dfvfs\test_data\).
$ vhdiinfo fat-differential.vhdx
Disk type : Differential
Media size : 4.0 MiB (4194304 bytes)
Identifier : e9e37682-8227-44a7-8648-39cb9f52e5e6
Parent identifier : f88d4d92-6fcc-408d-9bef-9b7c89f15c89
Parent filename : C:\Projects\dfvfs\test_data\fat-parent.vhdx
The reported parent identifier equals the parent's own
Identifier. Sector-provenance analysis over the composed
4 MiB VHD chain found 8180 sectors identical in both, 2 taken
from the parent, 10 from the child, and zero taken from
neither -- so every composed sector is exactly one file's, and
sectors 0 and 65536 came from the parent despite sitting inside a
child-allocated block. libvhdi honours the per-block sector
bitmap.
The sub-agent then wrote its own decoder from the spec and recomposed the same third-party bytes independently. Three of the four chains agreed byte for byte with libvhdi; the fourth disagreement is defect A below, and it reproduces on a Hyper-V image rather than only on ours.
Our generated chains¶
$ pyv compose.py vhdx-composed.raw vhdx-child.vhdx vhdx-parent.vhdx
wrote 16777216 bytes to vhdx-composed.raw
$ cmp vhdx-composed.raw vhdx-expected.raw ; echo $?
0
$ cmp vhdx-composed.raw vhdx-parent-only.raw ; echo $? -> 1 (differ: byte 513)
$ cmp vhdx-composed.raw vhdx-child-only.raw ; echo $? -> 1 (differ: byte 1)
The VHDX child exercises all three BAT states at once:
PARTIALLY_PRESENT with a real 1 MiB sector-bitmap block,
FULLY_PRESENT shadowing the parent, and NOT_PRESENT. The VHD chain composes byte-exactly too, once its child sectors
are kept out of shared bitmap bytes -- see defect A. Re-run in
the management session on 2026-09-05, closing the gap step 1f
found in this section (the VHD transcript was in
docs/quirks.md but not here, where the definition of done
requires it):
$ vhdiinfo thirdparty/fat-differential.vhd
vhdiinfo 20240509
Virtual Hard Disk image information:
Format : VHD (version 1)
Format version : 1.0
Disk type : Differential
Media size : 4.0 MiB (4194304 bytes)
Bytes per sector : 512 bytes
Identifier : f84f1636-cd9e-9041-a69e-dcc2380e416a
Parent identifier : 5fa21a55-f394-aa4d-9958-1951a67d5540
Parent filename : C:\Projects\dfvfs\test_data\fat-parent.vhd
$ pyv compose.py /tmp/vhd-oracle.raw chains-aligned/vhd-child.vhd chains-aligned/vhd-parent.vhd
media_size=16777216 disk_type=4
parent_identifier=11111111-2222-3333-4444-555555555555
parent_filename=.../chains-aligned/vhd-parent.vhd
wrote 16777216 bytes to /tmp/vhd-oracle.raw
$ cmp /tmp/vhd-oracle.raw chains-aligned/vhd-expected.raw ; echo $?
0
$ cmp /tmp/vhd-oracle.raw chains-aligned/vhd-parent-only.raw ; echo $?
/tmp/vhd-oracle.raw chains-aligned/vhd-parent-only.raw differ: byte 4097, line 1
1
$ cmp /tmp/vhd-oracle.raw chains-aligned/vhd-child-only.raw ; echo $?
/tmp/vhd-oracle.raw chains-aligned/vhd-child-only.raw differ: byte 1, line 1
1
vhdiinfo on the Hyper-V child names a parent it did not write;
the composition of our own byte-aligned chain matches its
intended content exactly and differs from both the parent-only
and child-only controls.
The generated parents were validated against a second
implementation before the oracle was asked anything:
qemu-img convert -f vpc -O raw and -f vhdx -O raw of each
parent cmp-match the intended raw content.
Two libvhdi defects, found and not fixed¶
- A. VHD sector bitmap decoded with an unmasked shift.
libvhdi_block_descriptor_read_sector_bitmap_datacomputesbyte_value >> (7 - bit_index)for VHD without masking to one bit, then treats a zero result as unallocated. Once any higher bit in a bitmap byte is set, every subsequent sector in that byte reads as present in the child. Measured on Hyper-V'sfat-differential.vhd(bitmap byte 23 =0xcb, sector 186 wrongly taken from the child) and reproduced deterministically: a probe placing one child sector at bit 7 of bitmap byte 1 predicted exactly seven wrong sectors, and exactly sectors 9 through 15 came back wrong. The VHDX branch is correct.
Impact. None on instar create, whose child has a wholly
unallocated BAT and no sector bitmaps at all. It matters in
phase 8 and phase 15: fixtures with partially populated blocks
must either keep parent-owned and child-owned sectors out of
the same bitmap byte, or account for the bug in their expected
output. Discovering this in phase 15 instead would look exactly
like an instar bug.
- B. VHDX
relative_pathkey never matches.libvhdi_metadata_values.clooks the key up with length 12 for a 13-character key. Measured by rewriting our child's locator with one key at a time:relative_pathalone yields no "Parent filename" line at all, whileabsolute_win32_pathandvolume_pathboth resolve. A differencing VHDX carrying only a relative path resolves nothing in libvhdi, thoughset_parentworks regardless since the harness supplies the parent.
Neither defect has been reported upstream. Whether to do so is left for the operator; it is not on this plan's critical path.
Structure findings that change what later phases must do¶
Every offset this plan already stated was confirmed against
Hyper-V bytes: footer disk type at 60 (4 for differencing),
dynamic header at 512, parent unique id at 552, parent timestamp
at 568, parent unicode name at 576 in UTF-16 big endian, the
eight 24-byte locator entries from 1088, and both checksums as
the ones-complement of the byte sum with the field zeroed. Five
things it did not say, each measured:
- Locator platform data is UTF-16 little endian -- the opposite of the parent unicode name field 512 bytes above it.
platform_data_spaceis a byte count, not a sector count, despite the Microsoft spec's wording. Hyper-V writesdata_space=4096, data_length=84for a locator at0x1000whose neighbour is at0x3000; read as sectors that would be 2 MiB and would overlap the BAT.- VHDX
parent_linkageis the parent'sDataWriteGuid, rendered as a braced GUID string in UTF-16LE, compared against the parent's active header, not its virtual disk id metadata item. This answers the master plan's ambiguity by measurement rather than by reading, and phase 6 implements it as fact. - The two sector bitmaps have opposite bit order. VHD is
MSB-first (sector i is bit
7 - i%8of bytei/8); VHDX is LSB-first. Phases 12 and 13 must not share a helper here without a parameter. - A VHDX child needs a sector-bitmap BAT entry at index
chunk_ratioper chunk (state 6,SB_BLOCK_PRESENT) whenever any payload block isPARTIALLY_PRESENT.
Also: Hyper-V writes parent_timestamp = 0 in a real
differencing VHD, and its footer CHS does not multiply out to
current_size. Neither field needs to be meaningful. And
libvhdi validates neither VHD checksum -- it reads a dfvfs image
whose footer and dynamic-header checksums are both wrong -- so
checksum correctness needs instar's own assertions.
The existing fixture, confirmed a type marker¶
vhd-differencing.vhd has disk_type = 4 at offset 60 and zeros
in the parent unique id, parent unicode name and all eight
locator entries. vhdiinfo agrees and prints no "Parent
filename" line at all, reporting
Parent identifier: 00000000-0000-0000-0000-000000000000;
reading it fails with "invalid file - missing parent file". Its
checksums are valid and its BAT has one allocated block, and its
companion vhd-diff-base.vhd is unreferenced by it.
Behaviours for step 1c to record¶
- libvhdi refuses a differencing child with no parent attached ("invalid file - missing parent file"), where qemu-img and instar silently return the child's blocks.
- libvhdi enforces the parent GUID ("mismatch in identifier") when the wrong parent is attached.
- qemu-img 10.0.11 refuses differencing VHDX outright
("Operation not supported") while silently mis-reading
differencing VHD -- the two formats are not symmetric in qemu,
and instar matches that asymmetry (
VhdxState::initrejects,VhdState::initaccepts). Re-verified in the management session:file(1)identifies libvhdi's composition offat-differential.vhdas an MBR boot sector and qemu's read of the same child as unidentifiabledata, because sector 0 is missing.
The issue filed (step 1d)¶
Filed as issue #547,
"VHD differencing (disk type 4): convert -O raw silently composes
wrong data, exits 0", labelled bug. The reproduction is against
instar-testdata/custom/format-coverage/vhd-differencing.vhd,
noting that fixture's own limitation (it is a type marker with a
zeroed locator table and parent unique id, not a resolvable
chain); the body also records the qemu-parity observation above
and points at phase 4 of this plan as the fix. Not fixed here, per
decision 6. The master plan's Bugs fixed during this work
section carries the same number; it is not duplicated here beyond
this pointer.
Risks and mitigations¶
- Correlated error. Our generator and our future emitter could share a misreading that libvhdi tolerates, making the oracle look sound while validating nothing. Mitigated by decision 3: step 1a must test a differencing image we did not produce, or downgrade the claim in writing. The management session checks specifically that this was done, because it is the step most likely to be quietly skipped.
- A tolerant oracle. libvhdi may resolve chains that Hyper-V would reject, so passing it is necessary and not sufficient. Mitigated by keeping step 1b's structural assertions as a second, independent check, and by phase 8 asserting structure as well as content.
- Spec ambiguity on VHDX
parent_linkage. Mitigated by step 1b naming the interpretation explicitly so phase 6 implements a decision rather than a guess, and by flagging it for revisit if a Hyper-V sample later contradicts it. - A buggy oracle in one specific place. libvhdi's VHD sector-bitmap decoder is wrong (defect A above), so a content-exact assertion against a VHD chain whose child and parent sectors share a bitmap byte will fail for reasons that are not instar's. Mitigated by recording it here, and by phases 8 and 15 choosing fixture sector layouts that avoid shared bitmap bytes unless they are deliberately testing this.
- Scope creep into phase 4. The defect is in front of the
sub-agent in steps 1c and 1d and it is a small fix. Mitigated
by decision 6 and by the
git diff -- src/check in the definition of done. - The go/no-go comes back no. Then phases 8, 9 and 15 change shape and the operator should hear it immediately rather than after phase 5. Step 1a reports to the management session before 1b starts.
Definition of done¶
vhdiinfo -Voutput is recorded in this plan, and a go/no-go sentence names the oracle or the fallback taken.- This plan contains, for both VHD and VHDX, the verbatim command
lines and output of an oracle run against a differencing chain,
and a
cmpresult against the intended composed content. - This plan states whether a differencing image not produced by us was tested, and names it or says it could not be obtained.
- Every offset stated in the structure pin is backed by a spec
citation or an
xxdof a real image, both present in the plan. docs/quirks.mdhas a differencing section whose every factual claim quotes a command run during this phase, including tool versions.- A GitHub issue exists for the silent parent-ignoring read, and its number appears in the master plan's Bugs fixed during this work.
- No open question in the master plan is left as a recommendation: each of questions 1 through 7 reads RESOLVED with an answer and its evidence.
git diff --name-only develop...HEAD -- src/is empty.pre-commit run --all-filespasses.
The structure pin¶
This is the field-by-field emit specification for phases 5 and 6.
It is written so that neither emitter has to rediscover anything:
every row gives an absolute byte offset, a size, an endianness and
the value or rule instar writes, and every offset is backed either
by a spec citation or by an xxd of a real image, with the
command line.
Three sources are used, and they are named per claim rather than blended:
- SPEC(VHD) — Microsoft, Virtual Hard Disk Image Format
Specification, version 1.0 (October 2006). This document is no
longer served from microsoft.com. The tables are quoted here via
libyal's transcription,
libyal/libvhdi/documentation/Virtual Hard Disk (VHD) image format.asciidoc(fetched 2026-09-05), in which yellow-highlighted text is marked as copied verbatim from the Microsoft specification. Where a claim rests on that verbatim text it is called out; where it rests on libyal's own reverse-engineering it is called out as LIBYAL instead, which is a weaker source than a spec. - SPEC(VHDX) —
[MS-VHDX]: Virtual Hard Disk v2 (VHDX) File Format, revision 8.0 (2024-04-23), cited by section number and read fromlearn.microsoft.com/en-us/openspecs/windows_protocols/ms-vhdx/on 2026-09-05. - MEASURED — an
xxd, a tool run or a source read performed for this step on 2026-09-05. Paths beginningstep1a/are relative to the phase 1 scratchpad directory recorded in the step 1a result;thirdparty/inside it holds the Hyper-V producedlog2timeline/dfvfscorpus. Paths beginningsrc/are in this repository atd59cc40.
The Hyper-V images used throughout are fat-differential.vhd,
ntfs-differential.vhd, fat-differential.vhdx and
ntfs-differential.vhdx, all with creator application win and
creator version 0xa0000, and their parents. Every field below
was checked in both the fat and the ntfs chain; only the fat
xxd is quoted, because the two agree on every structural value
and differ only in paths, sizes and identifiers.
VHD — the footer of a differencing child¶
The footer is 512 bytes and is written twice: a head copy at byte 0 and a tail copy at the end of the file. Both copies are byte-identical in Hyper-V's images, checksum included. Offsets are relative to the start of the footer copy, so they are absolute for the head copy.
Layout: SPEC(VHD) "Footer" table. Values: MEASURED unless noted.
| Field | Offset | Size | Endianness | What instar writes |
|---|---|---|---|---|
| Cookie | 0 | 8 | ASCII | conectix |
| Features | 8 | 4 | BE u32 | 0x00000002 (reserved bit; SPEC(VHD) "Features": must always be set) |
| File format version | 12 | 4 | BE u32 | 0x00010000 |
| Data offset | 16 | 8 | BE u64 | 512 — the byte offset of the dynamic header |
| Timestamp | 24 | 4 | BE u32 | 0, as vhd::build_footer already does (src/crates/vhd/src/lib.rs:1065) |
| Creator application | 28 | 4 | ASCII | qem2 — keep it, see the note below |
| Creator version | 32 | 4 | BE u32 | 0x00010000 |
| Creator host OS | 36 | 4 | ASCII | Wi2k |
| Original size | 40 | 8 | BE u64 | the parent's current size |
| Current size | 48 | 8 | BE u64 | the same value |
| Disk geometry | 56 | 4 | BE u16 cylinders, u8 heads, u8 sectors/track | vhd::footer_geometry(current_size), unchanged |
| Disk type | 60 | 4 | BE u32 | 4 (differencing) |
| Checksum | 64 | 4 | BE u32 | ones' complement of the sum of all 512 bytes with these four bytes zeroed |
| Unique id | 68 | 16 | opaque 16 bytes | the child's own identifier |
| Saved state | 84 | 1 | — | 0 |
| Reserved | 85 | 427 | — | zero |
$ xxd -s 0 -l 96 step1a/thirdparty/fat-differential.vhd
00000000: 636f 6e65 6374 6978 0000 0002 0001 0000 conectix........
00000010: 0000 0000 0000 0200 2719 8e1b 7769 6e20 ........'...win
00000020: 000a 0000 5769 326b 0000 0000 0040 0000 ....Wi2k.....@..
00000030: 0000 0000 0040 0000 0078 0411 0000 0004 .....@...x......
00000040: ffff f02c f84f 1636 cd9e 9041 a69e dcc2 ...,.O.6...A....
00000050: 380e 416a 0000 0000 0000 0000 0000 0000 8.Aj............
Reading that back: data offset 0x200 at byte 16, original and
current size 0x400000 at 40 and 48, geometry 0078 04 11 =
(120, 4, 17) at 56, disk type 0x00000004 at 60, checksum
0xfffff02c at 64, unique id f84f1636… at 68. The parent's own
footer carries unique id 5fa21a55-f394aa4d-99581951-a67d5540,
which is exactly what the child's dynamic header repeats at
offset 552 below.
Three notes on the value column, each MEASURED:
- Creator application stays
qem2, notwin. Hyper-V writeswin; instar must not copy it.qem2is load-bearing: without it every qemu before 10.0 derives the disk size from the CHS geometry and silently truncates instar's output. The reasoning is already written out atsrc/crates/vhd/src/lib.rs:1067-1087and nothing about differencing changes it. - The geometry does not have to multiply out to the size.
Hyper-V's
120 * 4 * 17 = 8160sectors against acurrent_sizeof 8192 sectors. So instar's existingfooter_geometryoutput is acceptable for a differencing child, and no new geometry rule is needed. - Child size equals parent size. In both Hyper-V VHD chains the
child's
current_sizeequals the parent's (4194304). There is no SPEC(VHD) sentence requiring it, but the child's BAT is sized from its ownmax_table_entriesand a differing size gives a chain no reader can compose sensibly. Phase 5 should require it; see the judgement calls.
VHD — the dynamic header of a differencing child¶
The dynamic header is 1024 bytes at the footer's data offset, which is 512 for every image in the corpus and for everything instar emits. Both a header-relative and an absolute offset are given; the absolute column assumes that layout.
Layout: SPEC(VHD) "Dynamic disk header" table. Values: MEASURED unless noted.
| Field | Abs | Rel | Size | Endianness | What instar writes |
|---|---|---|---|---|---|
| Cookie | 512 | +0 | 8 | ASCII | cxsparse |
| Next offset | 520 | +8 | 8 | BE u64 | 0xFFFFFFFFFFFFFFFF |
| Table offset | 528 | +16 | 8 | BE u64 | absolute byte offset of the BAT |
| Header version | 536 | +24 | 4 | BE u32 | 0x00010000 |
| Max table entries | 540 | +28 | 4 | BE u32 | ceil(virtual_size / block_size) |
| Block size | 544 | +32 | 4 | BE u32 | 2 MiB default, unchanged |
| Checksum | 548 | +36 | 4 | BE u32 | ones' complement of the sum of all 1024 bytes with these four zeroed |
| Parent unique id | 552 | +40 | 16 | opaque 16 bytes | the parent footer's bytes 68..84, copied verbatim |
| Parent timestamp | 568 | +56 | 4 | BE u32 | 0 |
| Reserved | 572 | +60 | 4 | BE u32 | 0 |
| Parent unicode name | 576 | +64 | 512 | UTF-16 BIG endian | the backing path, zero-padded to 512 bytes |
| Parent locator entries 1..8 | 1088 | +576 | 8 x 24 = 192 | see below | two populated, six zero |
| Reserved | 1280 | +768 | 256 | — | zero |
$ xxd -s 512 -l 96 step1a/thirdparty/fat-differential.vhd
00000200: 6378 7370 6172 7365 ffff ffff ffff ffff cxsparse........
00000210: 0000 0000 0000 2000 0001 0000 0000 0002 ...... .........
00000220: 0020 0000 ffff d951 5fa2 1a55 f394 aa4d . .....Q_..U...M
00000230: 9958 1951 a67d 5540 0000 0000 0000 0000 .X.Q.}U@........
00000240: 0043 003a 005c 0050 0072 006f 006a 0065 .C.:.\.P.r.o.j.e
00000250: 0063 0074 0073 005c 0064 0066 0076 0066 .c.t.s.\.d.f.v.f
Table offset 0x2000 at 528, max table entries 2 at 540, block
size 0x200000 at 544, checksum 0xffffd951 at 548, parent
unique id 5fa2 1a55 f394 aa4d 9958 1951 a67d 5540 at 552 —
byte-identical to the parent file's own footer unique id — parent
timestamp 0 at 568, and the parent unicode name starting at 576.
The name field's endianness is visible in that last dump and is worth restating because it is the opposite of the locator data 512 bytes further on:
$ xxd -s 576 -l 64 step1a/thirdparty/fat-differential.vhd
00000240: 0043 003a 005c 0050 0072 006f 006a 0065 .C.:.\.P.r.o.j.e
00000250: 0063 0074 0073 005c 0064 0066 0076 0066 .c.t.s.\.d.f.v.f
00000260: 0073 005c 0074 0065 0073 0074 005f 0064 .s.\.t.e.s.t._.d
00000270: 0061 0074 0061 005c 0066 0061 0074 002d .a.t.a.\.f.a.t.-
00 43 00 3a is C: in UTF-16 big endian; SPEC(VHD) says so
too ("Contains an UTF-16 big-endian string"). The string is not
NUL terminated as such — the remainder of the 512-byte field is
simply zero, which amounts to the same thing. instar writes the
backing path here, zero-padding the rest.
A length rule phase 5 must add. The field is 512 bytes, so a
path is limited to 256 UTF-16 code units, but the call table
accepts up to MAX_BACKING_FILE_LEN = 1024 UTF-8 bytes
(src/crates/create/src/lib.rs:24). A path whose UTF-16 encoding
exceeds 512 bytes therefore needs a typed refusal rather than a
truncation. That is a new error variant, not an existing one.
Parent timestamp is 0. Hyper-V writes zero here in both
differencing VHDs (byte 568 above), so instar writing zero matches
the only real producer we have. SPEC(VHD) describes it as
"Parent modification time … seconds since January 1, 2000", so a
real value would also be legal; nothing reads it (see What has no
oracle).
VHD — the eight parent locator entries¶
Each entry is 24 bytes; the eight of them occupy absolute offsets 1088, 1112, 1136, 1160, 1184, 1208, 1232 and 1256.
Layout: SPEC(VHD) "Parent locator entry" table. Values: MEASURED.
| Field | Rel | Size | Endianness | What instar writes |
|---|---|---|---|---|
| Platform code | +0 | 4 | ASCII, not byte-swapped | W2ku or W2ru; zero for an unused slot |
| Platform data space | +4 | 4 | BE u32 | byte count, rounded up to a 512-byte multiple |
| Platform data length | +8 | 4 | BE u32 | length in bytes of the UTF-16LE string, no terminator |
| Reserved | +12 | 4 | BE u32 | 0 |
| Platform data offset | +16 | 8 | BE u64 | absolute byte offset of the string in the file |
$ xxd -s 1088 -l 96 step1a/thirdparty/fat-differential.vhd
00000440: 5732 6b75 0000 1000 0000 0054 0000 0000 W2ku.......T....
00000450: 0000 0000 0000 1000 5732 7275 0001 0000 ........W2ru....
00000460: 0000 0020 0000 0000 0000 0000 0000 3000 ... ..........0.
00000470: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000480: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000490: 0000 0000 0000 0000 0000 0000 0000 0000 ................
Entry 1 (absolute 1088): code W2ku, data space 0x00001000 =
4096, data length 0x54 = 84, reserved 0, data offset
0x0000000000001000. Entry 2 (absolute 1112): code W2ru, data
space 0x00010000 = 65536, data length 0x20 = 32, data offset
0x3000. Entries 3 to 8 are zero — the dump above covers entries
1 to 4, and xxd -s 1184 -l 96 on the same file shows entries 5
to 8 zero as well.
The locator data itself, at the offsets those entries name:
$ xxd -s 4096 -l 96 step1a/thirdparty/fat-differential.vhd
00001000: 4300 3a00 5c00 5000 7200 6f00 6a00 6500 C.:.\.P.r.o.j.e.
00001010: 6300 7400 7300 5c00 6400 6600 7600 6600 c.t.s.\.d.f.v.f.
00001020: 7300 5c00 7400 6500 7300 7400 5f00 6400 s.\.t.e.s.t._.d.
00001030: 6100 7400 6100 5c00 6600 6100 7400 2d00 a.t.a.\.f.a.t.-.
00001040: 7000 6100 7200 6500 6e00 7400 2e00 7600 p.a.r.e.n.t...v.
00001050: 6800 6400 0000 0000 0000 0000 0000 0000 h.d.............
$ xxd -s 12288 -l 48 step1a/thirdparty/fat-differential.vhd
00003000: 2e00 5c00 6600 6100 7400 2d00 7000 6100 ..\.f.a.t.-.p.a.
00003010: 7200 6500 6e00 7400 2e00 7600 6800 6400 r.e.n.t...v.h.d.
00003020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
43 00 3a 00 is C: in UTF-16 little endian, the opposite of
the parent unicode name field. 84 bytes is exactly the 42
characters of C:\Projects\dfvfs\test_data\fat-parent.vhd with no
NUL terminator, and 32 bytes is exactly the 16 characters of
.\fat-parent.vhd. The bytes past data_length up to
data_space are zero.
Where SPEC(VHD) and Hyper-V disagree: platform data space¶
SPEC(VHD)'s wording for this field, in the verbatim-from-Microsoft highlighting of libyal's transcription, is:
Platform data space. This field stores the number of 512-byte sectors needed to store the parent hard disk locator.
Hyper-V does not do that. In the dump above the W2ku entry has
data_space = 4096 for a locator at file offset 0x1000, and the
W2ru locator sits at 0x3000. Read as the spec's sector count,
4096 sectors is 2 MiB and would run from 0x1000 to 0x201000,
swallowing the BAT (whose offset the same header gives as
0x2000), the second locator's data at 0x3000, and the first
data block at 0x13e00 — in a file that is only 0x214000 bytes
long. Read as a byte count, 0x1000 + 4096 = 0x2000 lands exactly
on the BAT, which is what a producer laying the file out
sequentially would write. The same reading works for W2ru:
0x3000 + 65536 = 0x13000, below the first data block at
0x13e00. Both Hyper-V VHDs agree, and both values are 512-byte
multiples, so the two readings cannot be told apart by alignment
alone — only by the arithmetic above.
instar follows Hyper-V: platform_data_space is a byte count.
Hyper-V is the only producer of this structure we can measure, it
is the implementation the format exists to interoperate with, and
the sector reading is not merely unusual there but arithmetically
impossible. instar writes round_up(data_length, 512), which is
512 for any path short enough to fit the parent unicode name
field anyway.
What would falsify this: a Hyper-V or Windows-produced
differencing VHD in which data_space * 512 is the plausible
extent and data_space alone is not — that is, a locator whose
data is longer than data_space bytes. No image in the corpus is
like that, and none can be constructed from a 42-character path.
The file layout phase 5 should emit¶
Nothing external constrains this; it is stated so that phases 5, 8
and 10 describe the same file. It extends the dynamic layout
already documented at src/crates/create/src/lib.rs:740-744.
0 head footer copy (512 bytes)
512 dynamic header (1024 bytes)
1536 W2ru locator data (one 512-byte sector)
2048 W2ku locator data (one 512-byte sector)
2560 BAT, every entry 0xFFFFFFFF (sector-padded)
2560+bat_padded tail footer copy (512 bytes)
The BAT is wholly unallocated at create time, which SPEC(VHD)'s
block allocation table section covers directly: an entry of
0xffffffff means "block is sparse or stored in parent". A
freshly created differencing child therefore has no sector bitmaps
at all, which is why libvhdi defect A from the step 1a result
cannot affect instar create output.
VHDX — file parameters¶
The HasParent bit is what makes the file a differencing VHDX.
Layout and semantics: SPEC(VHDX) 2.6.2.1 File Parameters — "B - HasParent (1 bit): Specifies whether this file has a parent VHDX file. If set, the file is a differencing file, and one or more parent locators specify the location and identity of the parent. LeaveBlockAllocated is ignored when HasParent is set."
| Field | Item offset | Size | Endianness | What instar writes |
|---|---|---|---|---|
| BlockSize | +0 | 4 | LE u32 | the requested block size |
| LeaveBlockAllocated | +4 bit 0 | — | — | 0 |
| HasParent | +4 bit 1 | — | — | 1 |
$ xxd -s 0x210000 -l 16 step1a/thirdparty/fat-differential.vhdx
00210000: 0000 2000 0200 0000 0000 4000 0000 0000 .. .......@.....
0x00200000 = 2 MiB block size, flags 0x00000002 = HasParent
set, LeaveBlockAllocated clear. vhdx::build_metadata already
takes a has_parent argument and writes exactly this
(src/crates/vhdx/src/lib.rs:1479), so phase 6 passes true
rather than adding a field.
The child's BAT is left as a sparse hole of zeros, i.e. every
payload entry in state PAYLOAD_BLOCK_NOT_PRESENT (0) and every
sector-bitmap entry in SB_BLOCK_NOT_PRESENT (0). SPEC(VHDX)
2.5.1.1 makes that the right encoding: "For a differencing VHDX
file, this block state specifies that the block contents are not
present in the file and that the parent virtual disk SHOULD be
inspected to determine the associated contents." SPEC(VHDX)
2.5.1.2 makes the sector-bitmap side legal too: a sector bitmap
entry may only be SB_BLOCK_NOT_PRESENT if no associated payload
block is PAYLOAD_BLOCK_PARTIALLY_PRESENT, and a freshly created
child has none.
VHDX — the parent locator metadata item¶
The item is registered by a normal metadata table entry and its body is the parent locator header plus key/value entries.
| Thing | Value | Source |
|---|---|---|
| Metadata item GUID | A8D35F2D-B30B-454D-ABF7-D3D84834AB0C |
already in the tree as PARENT_LOCATOR_GUID, src/crates/vhdx/src/lib.rs:151; MEASURED below |
| Locator type GUID | B04AEFB7-D19E-4A81-B789-25B8E9445913 |
SPEC(VHDX) 2.6.2.6.3: "The only parent-locator type defined by this specification is the VHDX locator type with a GUID value of B04AEFB7-D19E-4A81-B789-25B8E9445913"; MEASURED below |
Both GUIDs are stored in the mixed-endian "bytes_le" form used everywhere in VHDX: first three groups little-endian, last two big-endian.
The metadata table entry, SPEC(VHDX) 2.6.1.2:
| Field | Entry offset | Size | Endianness | What instar writes |
|---|---|---|---|---|
| ItemID | +0 | 16 | GUID bytes_le | the parent locator GUID above |
| Offset | +16 | 4 | LE u32 | 0x10028, relative to the metadata region start |
| Length | +20 | 4 | LE u32 | the item's exact byte length |
| IsUser / IsVirtualDisk / IsRequired | +24 bits 0/1/2 | — | — | 0x00000004 — IsRequired only |
| Reserved2 | +28 | 4 | LE u32 | 0 |
$ xxd -s 0x2000c0 -l 32 step1a/thirdparty/fat-differential.vhdx
002000c0: 2d5f d3a8 0bb3 4d45 abf7 d3d8 4834 ab0c -_....ME....H4..
002000d0: 2800 0100 a202 0000 0400 0000 0000 0000 (...............
That is the sixth table entry (metadata region at 0x200000,
header 32 bytes, entries 32 bytes each, so entry index 5 is at
0x200000 + 32 + 5*32 = 0x2000c0): the parent locator GUID,
offset 0x00010028, length 0x2a2 = 674, flags 0x00000004.
The offset 0x10028 is not a coincidence instar has to reproduce
by hand — it is where instar's own build_metadata would place a
sixth item today. That function puts item data at items_base =
0x10000 and consumes 8 + 8 + 4 + 4 + 16 = 40 bytes for the five
existing items (src/crates/vhdx/src/lib.rs:1420-1504), so the
next free byte is 0x10028, exactly where Hyper-V puts its parent
locator. SPEC(VHDX) 2.6.1.2 requires only that the offset be at
least 64 KB and that items not overlap.
Parent locator header¶
SPEC(VHDX) 2.6.2.6.1 Parent Locator Header.
| Field | Item offset | Size | Endianness | What instar writes |
|---|---|---|---|---|
| LocatorType | +0 | 16 | GUID bytes_le | B04AEFB7-D19E-4A81-B789-25B8E9445913 |
| Reserved | +16 | 2 | LE u16 | 0 (SPEC: "MUST be set to 0") |
| KeyValueCount | +18 | 2 | LE u16 | the number of entries that follow |
Parent locator entry¶
SPEC(VHDX) 2.6.2.6.2 Parent Locator Entry. Entries are 12 bytes each and start at item offset +20.
| Field | Entry offset | Size | Endianness | Meaning |
|---|---|---|---|---|
| KeyOffset | +0 | 4 | LE u32 | offset within the metadata item |
| ValueOffset | +4 | 4 | LE u32 | offset within the metadata item |
| KeyLength | +8 | 2 | LE u16 | key length in bytes |
| ValueLength | +10 | 2 | LE u16 | value length in bytes |
SPEC(VHDX) 2.6.2.6.2 also fixes the string encoding: "The key and value strings are to be UNICODE strings with UTF-16 little-endian encoding. There must be no internal NUL characters, and the Length field must not include a trailing NUL character. The key string is case sensitive, and lowercase keys are recommended. All keys must be unique, and there is no ordering to the entries."
$ xxd -s 0x210028 -l 80 step1a/thirdparty/fat-differential.vhdx
00210028: b7ef 4ab0 9ed1 814a b789 25b8 e944 5913 ..J....J..%..DY.
00210038: 0000 0500 5000 0000 6c00 0000 1c00 4c00 ....P...l.....L.
00210048: b800 0000 de00 0000 2600 5600 3401 0000 ........&.V.4...
00210058: 4e01 0000 1a00 2200 7001 0000 8601 0000 N.....".p.......
00210068: 1600 b200 3802 0000 5602 0000 1e00 4c00 ....8...V.....L.
$ xxd -s 0x210078 -l 128 step1a/thirdparty/fat-differential.vhdx
00210078: 7000 6100 7200 6500 6e00 7400 5f00 6c00 p.a.r.e.n.t._.l.
00210088: 6900 6e00 6b00 6100 6700 6500 7b00 6600 i.n.k.a.g.e.{.f.
00210098: 3800 3800 6400 3400 6400 3900 3200 2d00 8.8.d.4.d.9.2.-.
002100a8: 3600 6600 6300 6300 2d00 3400 3000 3800 6.f.c.c.-.4.0.8.
002100b8: 6400 2d00 3900 6200 6500 6600 2d00 3900 d.-.9.b.e.f.-.9.
002100c8: 6200 3700 6300 3800 3900 6600 3100 3500 b.7.c.8.9.f.1.5.
002100d8: 6300 3800 3900 7d00 6100 6200 7300 6f00 c.8.9.}.a.b.s.o.
002100e8: 6c00 7500 7400 6500 5f00 7700 6900 6e00 l.u.t.e._.w.i.n.
Decoding entry 1 from 0x21003c: KeyOffset 0x50 = 80,
ValueOffset 0x6c = 108, KeyLength 0x1c = 28, ValueLength
0x4c = 76. Item start is 0x210028, so the key is at
0x210078 — parent_linkage, 14 characters, 28 bytes — and the
value at 0x210094, 38 characters, 76 bytes. That confirms
MEASURED what SPEC(VHDX) 2.6.2.6.2 states: the offsets are
relative to the start of the metadata item (the parent locator
header), not to the metadata region and not to the file. The five
entries decode to key offsets 80, 184, 308, 368, 568 and the item
ends exactly at 568 + 30 + 76 = 674, matching the table entry's
length with no padding.
Which keys Hyper-V writes — MEASURED¶
fat-differential.vhdx, in table order:
| Key | Value |
|---|---|
parent_linkage |
{f88d4d92-6fcc-408d-9bef-9b7c89f15c89} |
absolute_win32_path |
C:\Projects\dfvfs\test_data\fat-parent.vhdx |
relative_path |
.\fat-parent.vhdx |
volume_path |
\\?\Volume{5e0bd954-71b2-4bff-a928-082af7ab0f8f}\Projects\dfvfs\test_data\fat-parent.vhdx |
parent_linkage2 |
{00000000-0000-0000-0000-000000000000} |
ntfs-differential.vhdx writes the same five keys in the same
order, with its own paths and GUIDs. Both were read with
python3 step1a/vhdxdump.py step1a/thirdparty/fat-differential.vhdx
and cross-checked against the raw xxd above.
Two divergences from SPEC(VHDX) 2.6.2.6.3 fall out of that, both MEASURED:
- The spec says "The parent_linkage entry MUST be present, and
parent_linkage2 can't be present", and then two sentences later
says an implementation "MUST verify that the DataWriteGuid field
of the parent's header matches one of these two fields". The
section contradicts itself. Hyper-V writes
parent_linkage2with an all-zero GUID. instar writesparent_linkageonly: it satisfies the MUST, avoids the prohibition, and matches libvhdi, which never looks the second key up (libvhdi_metadata_values.creadsparent_linkageat :213 and nothing else GUID-shaped). - The spec says
absolute_win32_path"MUST begin with\\?\". Hyper-V's value isC:\Projects\dfvfs\test_data\fat-parent.vhdx, with no\\?\prefix. So the only real producer we can measure violates the MUST, and libvhdi accepts it. instar does not emit this key at all (see the recommendation below), so the question does not arise for the emitter — but phase 3's parser must not reject a value for lacking the prefix.
parent_linkage is the parent's DataWriteGuid — settled twice¶
Step 1a resolved this by measurement. It is now confirmed a second way, and the two agree, so phase 6 implements it as fact.
SPEC(VHDX) 2.6.2.6.3: "When a differencing VHDX file is created, the implementation MUST populate the parent's DataWriteGuid field in this field. When opening the parent VHDX file of a differencing VHDX, the implementation MUST verify that the DataWriteGuid field of the parent's header matches one of these two fields." The value is "encoded as a lowercase string with enclosing braces".
MEASURED, the child's parent_linkage against the parent's header:
$ xxd -s 0x20000 -l 48 step1a/thirdparty/fat-parent.vhdx
00020000: 6865 6164 2133 c2b1 0700 0000 0000 0000 head!3..........
00020010: 7fab 7d61 34f2 5b45 b63d 4d9d e0a3 06f2 ..}a4.[E.=M.....
00020020: 924d 8df8 cc6f 8d40 9bef 9b7c 89f1 5c89 .M...o.@...|..\.
The header signature head at 0x20000, sequence number 7 at
+8, FileWriteGuid at +16, and DataWriteGuid at +32:
92 4d 8d f8 cc 6f 8d 40 9b ef 9b 7c 89 f1 5c 89, which in
bytes_le form is f88d4d92-6fcc-408d-9bef-9b7c89f15c89 — exactly
the child's parent_linkage string, lowercase and braced. It is
not the parent's virtual disk id, which is
cc2e9979-9ee7-417c-a2cd-4a3fa18795fb.
libvhdi implements the same rule: libvhdi_file_set_parent_file
(libvhdi_file.c:2882) compares the child's
metadata_values->parent_identifier against the parent's
identifier, and for VHDX libvhdi_file_get_identifier (:3457) returns
libvhdi_image_header_get_data_write_identifier (:3508), not
the virtual disk id. The comparison and its "mismatch in
identifier" error are at :2992-3005.
Which header? The active one — the header with the higher
sequence number that passes its CRC. In this corpus both headers
of both parents carry the same DataWriteGuid, so the sample does
not discriminate; the rule is taken from SPEC(VHDX)'s "the
parent's header" plus instar's own existing active-header
selection at src/crates/vhdx/src/lib.rs:741-751, which already
picks the higher sequence number.
A trap for phase 6 that this exposes. instar's
vhdx::build_header derives the DataWriteGuid from the sequence
number alone (src/crates/vhdx/src/lib.rs:1341-1345), and
plan_vhdx always writes sequence numbers 1 and 2
(src/crates/create/src/lib.rs:964-966), as does the convert op
(src/operations/convert/src/main.rs:4469, :4492). Every VHDX
instar has ever written therefore has the same active-header
DataWriteGuid, 00000002-0000-0000-0200-000000000000. That is
read from the code, not measured on an instar-produced image, and
should be confirmed against a real one in phase 6. If it holds,
the parent-identity check is vacuous for instar-written chains —
any instar VHDX will satisfy any instar child's parent_linkage —
so phase 8 must build at least one negative test using a
third-party parent, and phase 6 should consider giving created
images a real DataWriteGuid.
Copied-from-parent metadata¶
SPEC(VHDX) 2.6.1.2 on the IsVirtualDisk flag: "When forking, an implementation MUST copy all metadata items with this field set in the existing VHDX file to the new file, while leaving items with this field clear."
Hyper-V does exactly that, MEASURED: the child's virtual_disk_id
item is byte-identical to the parent's, and both files' metadata
table entries carry flags 0x06 (IsVirtualDisk | IsRequired) on
virtual_disk_size, logical_sector_size, physical_sector_size
and virtual_disk_id, and 0x04 on file_parameters and
parent_locator.
$ xxd -s 0x210018 -l 16 step1a/thirdparty/fat-differential.vhdx
00210018: 7999 2ecc e79e 7c41 a2cd 4a3f a187 95fb y.....|A..J?....
$ xxd -s 0x210018 -l 16 step1a/thirdparty/fat-parent.vhdx
00210018: 7999 2ecc e79e 7c41 a2cd 4a3f a187 95fb y.....|A..J?....
$ xxd -s 0x200040 -l 128 step1a/thirdparty/fat-differential.vhdx
00200040: 2442 a52f 1bcd 7648 b211 5dbe d83b f4b8 $B./..vH..]..;..
00200050: 0800 0100 0800 0000 0600 0000 0000 0000 ................
00200060: 1dbf 4181 6fa9 0947 ba47 f233 a8fa ab5f ..A.o..G.G.3..._
00200070: 1000 0100 0400 0000 0600 0000 0000 0000 ................
00200080: c748 a3cd 5d44 7144 9cc9 e988 5251 c556 .H..]DqD....RQ.V
00200090: 1400 0100 0400 0000 0600 0000 0000 0000 ................
002000a0: ab12 cabe e6b2 2345 93ef c309 e000 c746 ......#E.......F
002000b0: 1800 0100 1000 0000 0600 0000 0000 0000 ................
Two consequences for phase 6, both of which change existing code rather than only adding to it:
- The child's virtual disk size must equal the parent's.
virtual_disk_sizecarries IsVirtualDisk, so forking copies it. This is the VHDX half of the "child size equals parent size" rule and it is spec-backed, where the VHD half is only measured. - instar writes flags
0x04on all five metadata entries (src/crates/vhdx/src/lib.rs:1422-1460) where Hyper-V writes0x06on four of them, and it synthesises the virtual disk id from the size and block size rather than using a GUID (:1491-1504). For a differencing child the emitter should copy the parent'svirtual_disk_idverbatim, which the guest can read from the parent device it already has attached. Whether to also correct the IsVirtualDisk flags on the non-differencing path is a phase 6 scoping call, not a differencing question.
Which platform codes to emit — the answer to open question 3¶
MEASURED, both Hyper-V VHDs. Exactly two locator entries are populated, in this slot order:
| Slot | Absolute offset | Platform code | Contents |
|---|---|---|---|
| 1 | 1088 | W2ku |
the absolute Windows path, UTF-16LE |
| 2 | 1112 | W2ru |
the path relative to the child, UTF-16LE |
| 3-8 | 1136-1256 | zero | — |
That is 57 32 6b 75 and 57 32 72 75 on the wire, stored as
plain ASCII in file order and not byte-swapped — read straight off
the xxd at 1088 above. SPEC(VHD)'s "Locator platform code" table
lists W2ku as "Absolute Unicode (UTF-16) pathname on Windows"
and W2ru as "Unicode path (UTF-16) on Windows relative to the
differential disk path", with Wi2k/Wi2r deprecated and
MacX/Max for Mac OS. Hyper-V uses neither deprecated code and
neither Mac code.
The recommendation: emit W2ru only, in slot 1, and leave slots
2 through 8 zero — unless the user's backing path is absolute, in
which case emit W2ku in slot 1 instead. One entry, whose
platform code describes the string instar actually has.
The reason is a constraint on the guest, MEASURED in the host
code: the create op receives the backing path exactly as the user
typed it on the command line and nothing else. run_create_nonraw
resolves the typed path against the output's directory for
opening the file but sends typed_backing.as_bytes() to the
guest unchanged (src/vmm/src/main.rs:16710-16769). So the guest
has one string. It cannot construct an absolute path from a
relative one, and it cannot construct a relative one from an
absolute one. Writing both entries would mean fabricating the
second — .\<basename> as a stand-in for a relative path, which
is simply wrong whenever the parent is in a different directory
from the child, and a fabricated locator is worse than an absent
one. The alternative, passing a second resolved path from the
host, needs a new call-table field, and the master plan's premise
that this work needs no call-table change (Execution, phase 7
rationale) is worth more than a cosmetic match to Hyper-V.
Emitting one entry rather than two costs nothing that we can measure: no reader in reach parses the VHD locator table at all (see What has no oracle), and the parent unicode name at offset 576 — which libvhdi does use — carries the same string anyway.
Slot order is cosmetic. Nothing in SPEC(VHD) orders the entries, and instar's own phase 3 parser should select by platform code rather than by slot, so putting the single entry in slot 1 is a readability choice.
What would falsify this: a Hyper-V or Windows sample that
refuses a differencing child carrying only W2ru, or only
W2ku. We cannot run that test — there is no Windows host in this
plan's reach — so this recommendation is a reasoned default rather
than a measured one, and it is cheap to revisit: adding a second
entry later is an additive change to the emitter and to nothing
else.
A related judgement call, stated separately because it is a
security question rather than a format one: W2ku writes an
absolute host path into the image, which is an information
disclosure of the producer's filesystem layout. instar already
stores the user-supplied backing_file string verbatim for qcow2
and vmdk, so writing what the user typed — and only what the user
typed — is consistent with existing behaviour and discloses
nothing the user did not choose to disclose. The recommendation
above preserves that property; a scheme that resolved the path to
absolute before writing it would not.
VHDX — which keys instar should write¶
Write parent_linkage and exactly one path key, chosen the
same way as the VHD platform code: relative_path when the typed
backing path is relative, absolute_win32_path when it is
absolute. Do not write volume_path — it requires a Windows
volume GUID that no Linux producer can obtain — and do not write
parent_linkage2, per the spec sentence above.
parent_linkage is required by SPEC(VHDX) 2.6.2.6.3 and is the
key libvhdi checks, so it is non-negotiable. SPEC(VHDX) 2.6.2.6.3
also requires "At least one entry with key value of
relative_path, volume_path, or absolute_win32_path".
The libvhdi bug phase 6 must not mistake for its own. libvhdi
looks the path keys up in the order absolute_win32_path,
volume_path, relative_path (libvhdi_metadata_values.c:249,
:269, :290) — the reverse of the order SPEC(VHDX) 2.6.2.6.3
prescribes — and it looks relative_path up with a length of 12
for a 13-character key:
result = libvhdi_parent_locator_get_entry_by_utf8_key(
parent_locator,
(uint8_t *) "relative_path",
12,
So a differencing VHDX carrying only relative_path produces no
"Parent filename" line from vhdiinfo at all. That is the oracle
being broken, not instar's output. Step 1a demonstrated it by
rewriting the child's locator with one key at a time;
absolute_win32_path and volume_path both resolved and
relative_path alone did not. Composition through
pyvhdi.file.set_parent() is unaffected, because the harness
supplies the parent file object rather than asking libvhdi to find
it — which is how phase 15's harness will work. Phase 6 should
expect vhdiinfo to print no parent filename for a
relative-only child and must not "fix" its emitter in response.
What has no oracle¶
Step 1a established that libvhdi ignores the VHD locator table entirely and validates neither VHD checksum. Measuring the individual fields for this pin extended that list, and corrected one item on it.
Correction to the step 1a result. Step 1a wrote that "libvhdi
validates neither VHD checksum — so checksum correctness needs
instar's own assertions". The first half is right and the
conclusion is too strong: qemu-img validates the head footer's
checksum and refuses the image on a mismatch. MEASURED, by
corrupting one field at a time in a copy of fat-differential.vhd
and running qemu-img info (qemu-img 10.0.11):
| Corrupted | qemu-img 10.0.11 | libvhdi 20240509 |
|---|---|---|
| head footer checksum, offset 64 | Could not open: Incorrect header checksum |
opens, resolves the parent |
| dynamic header checksum, offset 548 | opens, reports a 4 MiB vpc image | opens, resolves the parent |
| tail footer copy's checksum | opens | opens |
So the footer checksum does have an external oracle. The dynamic header checksum does not.
The list of emitted fields that no external tool checks for us, and that phase 8's own structural assertions are therefore the only defence for:
- All eight VHD parent locator entries, and the locator data
they point at. Every field: platform code, data space, data
length, reserved, data offset, and the UTF-16LE bytes. Proven
by overwriting all 192 bytes at offset 1088 with
0xAAin a copy offat-differential.vhd, repairing the dynamic header checksum so only the table was under test, and re-running both tools:qemu-img inforeported the same 4 MiB vpc image, andvhdiinfostill printed the correct parent identifier and parent filename. libvhdi then composed the mutilated child against the real parent and the result was byte-identical to the composition of the pristine child (cmpexit 0). In source terms,libvhdi_parent_locator_*is called only fromlibvhdi_metadata_values.c, the VHDX metadata path; the VHD dynamic disk header reader touches the 192-byte array once, to hex-dump it inside a debug block (libvhdi_dynamic_disk_header.c:433-438). - The VHD dynamic header checksum, offset 548. Neither tool
validates it (table above). Only instar's own
checkdoes (src/operations/check/src/main.rs:2047-2052). - The tail footer copy's checksum. qemu-img reads only the
head copy for a dynamic or differencing image; instar's
checkvalidates the tail copy atsrc/operations/check/src/main.rs:1883-1890. - The VHD parent timestamp, offset 568. Nothing reads it.
- The VHD footer geometry of a differencing child. Hyper-V's
own CHS does not multiply out to
current_size, so no consistency assertion is even available. - VHD creator application, creator version, creator host OS,
features and saved state in a differencing child.
creator_applicationhas an indirect oracle only through theqem2size-interpretation behaviour, which is a size check rather than a field check. - Every VHDX parent locator key after the first one libvhdi
resolves. libvhdi stops at the first of
absolute_win32_path,volume_path,relative_paththat it finds, and itsrelative_pathlookup never matches at all. If instar emits an absolute key, the relative key is unread; if it emits only the relative key, nothing reads any path. - VHDX
parent_linkage2, which instar does not emit and nothing validates either way. - VHDX metadata table entry flag bits. libvhdi decodes
IsUser / IsVirtualDisk / IsRequired only for debug output
(
libvhdi_metadata_table_entry.c:222-285, one#if defined( HAVE_DEBUG_OUTPUT )block), and qemu-img refuses differencing VHDX outright so it checks nothing at all. - The VHDX child's copied
virtual_disk_id. Nothing verifies that it matches the parent's, though SPEC(VHDX) 2.6.1.2 requires the copy and Hyper-V performs it.
What does have an oracle, so that phase 8 does not spend
assertions twice: the VHD footer's head-copy checksum, disk type,
current size and data offset (qemu-img opens the file and reports
the size); the dynamic header's table offset, max table entries
and block size (qemu-img walks the BAT); the parent unicode name
and the parent unique id (libvhdi prints the first and enforces
the second in set_parent); the VHDX HasParent bit, parent
locator item GUID, locator type GUID, header, entry encoding,
parent_linkage and whichever path key libvhdi resolves; and the
VHDX BAT states, through composition.
Judgement calls¶
Each of these is a decision phase 5 or 6 implements, not a fact. The call, the reasoning and the falsifier are given so that a later reader can overturn one without re-deriving the rest.
platform_data_spaceis a byte count. Reasoning and falsifier are in the VHD locator section above. This one is nearly forced: the sector reading is arithmetically impossible against Hyper-V's own file.- One locator entry, not two. Detailed above. Falsified by any Windows-side rejection of a single-entry child.
- Path separators are not translated.
W2ru/relative_pathare Windows-shaped fields — SPEC(VHD) callsW2rua path "on Windows" and SPEC(VHDX) 2.6.2.6.3 saysrelative_pathuses "\as the path separator". instar writes the user's POSIX path verbatim, forward slashes and all, and does not translate them. Reasoning: instar's own chain resolution is POSIX, a translation is not round-trippable through a filename that legitimately contains a backslash, and no reader in reach cares — libvhdi never reads the VHD entry and treats the VHDX value as an opaque string. Falsified by a Hyper-V sample that refuses a forward-slash locator, or by a decision in phase 11 to make instar's own resolver Windows-path-aware, in which case the translation should live in the resolver and not in the emitter. - The child's virtual size must equal the parent's. For VHDX
this is spec-backed (SPEC(VHDX) 2.6.1.2's IsVirtualDisk copy
rule applied to
virtual_disk_size). For VHD it is measured in both Hyper-V chains and not stated anywhere. The call is to require it for both and refuse a mismatch with a typed error, and to letcreate -b PARENTwith no explicit size default to the parent's size. Falsified by a Hyper-V VHD chain whose child and parent sizes differ. - The child's parent unicode name carries the typed path, not
an absolute one. Hyper-V writes an absolute path there.
instar writes what the user typed, matching what it already
does with qcow2 and vmdk backing references and keeping the
image portable across moves. Falsified if phase 11's resolver
turns out to need an absolute form to disambiguate, which it
should not:
discover_backing_chainresolves relative references against the child's directory (src/vmm/src/main.rs:2416). parent_timestampstays0. Matches Hyper-V. A real modification time would also be legal and would make output non-reproducible, which this codebase avoids elsewhere. Falsified by an implementation that rejects a zero timestamp; none in reach does.- The child's own footer unique id.
plan_vhdcurrently writes sixteen zero bytes for every image it creates (UUID_ZERO,src/crates/create/src/lib.rs:776,:820). A differencing child copies its parent's id into the dynamic header at offset 552, so if the parent is itself an instar-created VHD, that field is sixteen zeros and libvhdi'sset_parentidentity check passes against any other instar-created VHD. The call for phase 5 is to leave the existing behaviour alone — changing the id of every created VHD is out of this plan's scope — and to make phase 8 test the identity check against a third-party parent, where the id is real, rather than against an instar-created one. Falsified if phase 8 finds it cannot construct a meaningful negative test that way, in which case giving created images a real id becomes in scope.
parent_linkage is deliberately absent from this list: step 1a
resolved it by measurement, this step confirmed it independently
against SPEC(VHDX) 2.6.2.6.3 and against libvhdi's source, and the
three agree. It is the parent's DataWriteGuid, taken from the
parent's active header, rendered as a lowercase braced GUID string
in UTF-16LE. Phase 6 implements it as fact.
Appendix — the throwaway generator¶
Per decision 2 this lives here rather than in a repository. Phase
2 lifts it into a maintained generator in instar-testdata. It
encodes the structure findings above, including the byte-count
platform_data_space and the opposite bitmap bit orders, and its
--vhd-plan flag selects between a realistic mixed layout, a
byte-aligned layout that avoids libvhdi defect A, and a probe
that demonstrates the defect.
#!/usr/bin/env python3
"""Throwaway generator for instar PLAN-differencing phase 1 step 1a.
Builds two differencing chains of 16 MiB each, plus the raw image each chain is
intended to compose to:
vhd-parent.vhd dynamic VHD (hand written, disk type 3)
vhd-child.vhd differencing VHD (hand written, disk type 4)
vhd-expected.raw what vhd-child.vhd + vhd-parent.vhd must read as
vhdx-parent.vhdx dynamic VHDX (qemu-img, 1 MiB blocks)
vhdx-child.vhdx differencing VHDX (qemu-img dynamic, then patched here)
vhdx-expected.raw what vhdx-child.vhdx + vhdx-parent.vhdx must read as
Also written, as controls:
*-parent-only.raw the parent's content alone
*-child-only.raw what a reader that ignores the parent would produce
Only the Python standard library and qemu-img are used. Nothing is written
outside the output directory.
Structure facts this encodes, each measured against a Hyper-V produced image
from the log2timeline/dfvfs corpus rather than taken from the spec text:
* VHD footer disk type is at offset 60, 4 == differencing.
* VHD dynamic header sits at footer.data_offset (512 here); parent unique id
is at header+40 (absolute 552), parent timestamp at +56 (568), parent
unicode name at +64 (576) and the eight 24 byte parent locator entries at
+576 (1088).
* The parent unicode name is UTF-16 BIG endian. The parent locator platform
data for W2ku/W2ru is UTF-16 LITTLE endian.
* Parent locator platform_data_space is a BYTE count, not the sector count
the Microsoft spec's wording implies.
* Both VHD checksums are the ones' complement of the sum of the structure's
bytes with the checksum field zeroed.
* The VHD per block sector bitmap is most significant bit first: virtual
sector i of the block is bit (7 - i % 8) of byte i // 8. A set bit means
the sector lives in this file, a clear bit means read it from the parent.
* VHDX has_parent is bit 1 (0x2) of the file parameters flags.
* The VHDX parent locator metadata item is A8D35F2D-B30B-454D-ABF7-D3D84834AB0C
with locator type B04AEFB7-D19E-4A81-B789-25B8E9445913; keys and values are
UTF-16 LITTLE endian and are not NUL terminated.
* The VHDX parent_linkage value is the parent's DataWriteGuid rendered as a
braced GUID string.
* The VHDX sector bitmap is least significant bit first, the opposite of VHD.
"""
import argparse
import os
import struct
import subprocess
import sys
import time
import uuid
SECTOR = 512
IMAGE_SIZE = 16 * 1024 * 1024
IMAGE_SECTORS = IMAGE_SIZE // SECTOR # 32768
VHD_BLOCK_SIZE = 2 * 1024 * 1024
VHD_SECTORS_PER_BLOCK = VHD_BLOCK_SIZE // SECTOR # 4096
VHDX_BLOCK_SIZE = 1024 * 1024
VHDX_SECTORS_PER_BLOCK = VHDX_BLOCK_SIZE // SECTOR # 2048
VHD_DISK_TYPE_DYNAMIC = 3
VHD_DISK_TYPE_DIFFERENCING = 4
# --- content plan -----------------------------------------------------------
#
# VHD: 2 MiB blocks, so block b covers sectors [b * 4096, (b + 1) * 4096).
# The child allocates blocks 0 and 2 only, and inside those blocks its sector
# bitmap claims only the sectors it actually wrote. Everything else, including
# sectors inside blocks 0 and 2 that the child did not claim, must come from
# the parent.
#
# Two sector plans are offered. "mixed" puts parent-owned and child-owned
# sectors in the same bitmap byte, which is what a real differencing disk looks
# like. "byte-aligned" keeps every child sector in a bitmap byte of its own,
# with no parent sector in the same byte. The difference between the two
# matters: see the note on libvhdi's unmasked shift in the report.
VHD_SECTOR_PLANS = {
# Sector 1 appears in both lists: the child must win.
'mixed': ([0, 1, 2, 100, 4096, 5000, 28672, 32767], [1, 3, 200, 8192, 9000]),
'byte-aligned': ([0, 2, 8, 100, 4096, 5000, 28672, 32767], [8, 200, 8192, 9000]),
# One child sector at bit 7 of bitmap byte 1, with parent data in every
# other sector that byte covers. A correct reader returns the parent for
# sectors 9 to 15.
'bug-probe': ([9, 10, 11, 12, 13, 14, 15, 100], [8]),
}
VHD_PARENT_SECTORS, VHD_CHILD_SECTORS = VHD_SECTOR_PLANS['mixed']
# VHDX: 1 MiB blocks, so block b covers sectors [b * 2048, (b + 1) * 2048).
# block 0 PAYLOAD_BLOCK_PARTIALLY_PRESENT + sector bitmap
# block 3 PAYLOAD_BLOCK_FULLY_PRESENT (shadows the parent completely)
# others PAYLOAD_BLOCK_NOT_PRESENT (read from the parent)
VHDX_PARENT_SECTORS = [0, 5, 2048, 3000, 7000, 10240, 32767]
VHDX_CHILD_SECTORS = [1, 5, 6144, 6200]
VHDX_CHILD_BLOCKS_PARTIAL = [0]
VHDX_CHILD_BLOCKS_FULL = [3]
def marker(tag, n):
"""A 512 byte sector whose content names its origin and its sector number."""
stamp = ('%s-sector-%06d.' % (tag, n)).encode('ascii')
return (stamp * (SECTOR // len(stamp) + 1))[:SECTOR]
def build_raw(sectors, tag, size=IMAGE_SIZE):
data = bytearray(size)
for n in sectors:
data[n * SECTOR:(n + 1) * SECTOR] = marker(tag, n)
return bytes(data)
# --- VHD --------------------------------------------------------------------
def vhd_checksum(buf, offset):
tmp = bytearray(buf)
tmp[offset:offset + 4] = b'\x00\x00\x00\x00'
return (~sum(tmp)) & 0xFFFFFFFF
def vhd_timestamp(when=None):
"""Seconds since 2000-01-01T00:00:00Z, the VHD epoch."""
base = 946684800
return int((when if when is not None else time.time()) - base)
def vhd_geometry(total_sectors):
"""A CHS triple whose product is exactly total_sectors, for 16 MiB."""
for heads in (16, 8, 4, 2, 1):
for spt in (63, 32, 17, 16, 8):
if total_sectors % (heads * spt) == 0:
cyls = total_sectors // (heads * spt)
if 0 < cyls <= 0xFFFF:
return cyls, heads, spt
raise ValueError('no exact geometry for %d sectors' % total_sectors)
def vhd_footer(disk_type, size, unique_id, timestamp, data_offset=512):
cyls, heads, spt = vhd_geometry(size // SECTOR)
buf = bytearray(512)
struct.pack_into('>8sIIQI4sI4sQQHBBI', buf, 0,
b'conectix', # cookie
0x00000002, # features: reserved bit
0x00010000, # file format version 1.0
data_offset, # data offset -> dynamic header
timestamp,
b'qem2', # creator application
0x00010000, # creator version
b'Wi2k', # creator host OS
size, # original size
size, # current size
cyls, heads, spt, # disk geometry
disk_type)
buf[68:84] = unique_id
buf[84] = 0 # saved state
struct.pack_into('>I', buf, 64, vhd_checksum(buf, 64))
return bytes(buf)
def vhd_locator_entry(platform_code, data_space, data_length, data_offset):
return struct.pack('>4sIIIQ', platform_code, data_space, data_length, 0, data_offset)
def vhd_dynamic_header(table_offset, max_entries, block_size,
parent_uid=b'\x00' * 16, parent_timestamp=0,
parent_name='', locators=()):
buf = bytearray(1024)
struct.pack_into('>8sQQIII', buf, 0,
b'cxsparse',
0xFFFFFFFFFFFFFFFF, # next offset
table_offset,
0x00010000, # header version 1.0
max_entries,
block_size)
buf[40:56] = parent_uid
struct.pack_into('>I', buf, 56, parent_timestamp)
struct.pack_into('>I', buf, 60, 0) # reserved
name = parent_name.encode('utf-16-be')
if len(name) > 512:
raise ValueError('parent name too long')
buf[64:64 + len(name)] = name
for i, entry in enumerate(locators):
buf[576 + i * 24:576 + (i + 1) * 24] = entry
struct.pack_into('>I', buf, 36, vhd_checksum(buf, 36))
return bytes(buf)
def vhd_sector_bitmap(sector_numbers, block_index):
"""MSB-first per-block sector bitmap, one 512 byte sector for 2 MiB blocks."""
nbytes = VHD_SECTORS_PER_BLOCK // 8
nbytes = ((nbytes + SECTOR - 1) // SECTOR) * SECTOR
bitmap = bytearray(nbytes)
first = block_index * VHD_SECTORS_PER_BLOCK
for n in sector_numbers:
if first <= n < first + VHD_SECTORS_PER_BLOCK:
i = n - first
bitmap[i // 8] |= 0x80 >> (i % 8)
return bytes(bitmap)
def write_dynamic_vhd(path, content, unique_id, timestamp):
"""A plain dynamic VHD holding content; every non-zero block is allocated."""
nblocks = (len(content) + VHD_BLOCK_SIZE - 1) // VHD_BLOCK_SIZE
bat_offset = 1536
bat_bytes = ((nblocks * 4 + SECTOR - 1) // SECTOR) * SECTOR
data_start = bat_offset + bat_bytes
bitmap_bytes = len(vhd_sector_bitmap([], 0))
bat = [0xFFFFFFFF] * nblocks
blocks = []
cursor = data_start
for b in range(nblocks):
chunk = content[b * VHD_BLOCK_SIZE:(b + 1) * VHD_BLOCK_SIZE]
if not any(chunk):
continue
bat[b] = cursor // SECTOR
blocks.append((cursor, b'\xff' * bitmap_bytes + chunk))
cursor += bitmap_bytes + VHD_BLOCK_SIZE
footer = vhd_footer(VHD_DISK_TYPE_DYNAMIC, len(content), unique_id, timestamp)
header = vhd_dynamic_header(bat_offset, nblocks, VHD_BLOCK_SIZE)
with open(path, 'wb') as fh:
fh.write(footer)
fh.write(header)
fh.write(struct.pack('>%dI' % nblocks, *bat))
fh.write(b'\x00' * (bat_bytes - nblocks * 4))
for offset, payload in blocks:
fh.seek(offset)
fh.write(payload)
fh.seek(cursor)
fh.write(footer)
return unique_id
def write_differencing_vhd(path, child_sectors, size, unique_id, timestamp,
parent_uid, parent_path, parent_relative):
"""A differencing VHD whose sector bitmaps claim only child_sectors."""
nblocks = (size + VHD_BLOCK_SIZE - 1) // VHD_BLOCK_SIZE
bitmap_bytes = len(vhd_sector_bitmap([], 0))
# Layout: footer copy | dynamic header | locator data | BAT | blocks | footer
loc_rel_offset = 1536
loc_abs_offset = loc_rel_offset + SECTOR
bat_offset = loc_abs_offset + SECTOR
bat_bytes = ((nblocks * 4 + SECTOR - 1) // SECTOR) * SECTOR
data_start = bat_offset + bat_bytes
rel_blob = parent_relative.encode('utf-16-le')
abs_blob = parent_path.encode('utf-16-le')
if len(rel_blob) > SECTOR or len(abs_blob) > SECTOR:
raise ValueError('locator data does not fit in one sector')
locators = [
# data_space is a byte count: that is what Hyper-V writes, despite the
# Microsoft spec describing it as a sector count.
vhd_locator_entry(b'W2ru', SECTOR, len(rel_blob), loc_rel_offset),
vhd_locator_entry(b'W2ku', SECTOR, len(abs_blob), loc_abs_offset),
]
touched = {}
for n in child_sectors:
touched.setdefault(n // VHD_SECTORS_PER_BLOCK, []).append(n)
bat = [0xFFFFFFFF] * nblocks
blocks = []
cursor = data_start
for b in sorted(touched):
payload = bytearray(VHD_BLOCK_SIZE)
first = b * VHD_SECTORS_PER_BLOCK
for n in touched[b]:
i = n - first
payload[i * SECTOR:(i + 1) * SECTOR] = marker('CHILD', n)
bat[b] = cursor // SECTOR
blocks.append((cursor, vhd_sector_bitmap(touched[b], b) + bytes(payload)))
cursor += bitmap_bytes + VHD_BLOCK_SIZE
footer = vhd_footer(VHD_DISK_TYPE_DIFFERENCING, size, unique_id, timestamp)
header = vhd_dynamic_header(bat_offset, nblocks, VHD_BLOCK_SIZE,
parent_uid=parent_uid,
parent_timestamp=timestamp,
parent_name=parent_path,
locators=locators)
with open(path, 'wb') as fh:
fh.write(footer)
fh.write(header)
fh.seek(loc_rel_offset)
fh.write(rel_blob)
fh.seek(loc_abs_offset)
fh.write(abs_blob)
fh.seek(bat_offset)
fh.write(struct.pack('>%dI' % nblocks, *bat))
fh.write(b'\x00' * (bat_bytes - nblocks * 4))
for offset, payload in blocks:
fh.seek(offset)
fh.write(payload)
fh.seek(cursor)
fh.write(footer)
# --- VHDX -------------------------------------------------------------------
REGION_BAT = uuid.UUID('2DC27766-F623-4200-9D64-115E9BFD4A08')
REGION_METADATA = uuid.UUID('8B7CA206-4790-4B9A-B8FE-575F050F886E')
META_FILE_PARAMETERS = uuid.UUID('CAA16737-FA36-4D43-B3B6-33F0AA44E76B')
META_PARENT_LOCATOR = uuid.UUID('A8D35F2D-B30B-454D-ABF7-D3D84834AB0C')
PARENT_LOCATOR_TYPE = uuid.UUID('B04AEFB7-D19E-4A81-B789-25B8E9445913')
VHDX_BAT_NOT_PRESENT = 0
VHDX_BAT_FULLY_PRESENT = 6
VHDX_BAT_PARTIALLY_PRESENT = 7
VHDX_SB_PRESENT = 6
METADATA_FLAG_IS_USER = 0x1
METADATA_FLAG_IS_VIRTUAL_DISK = 0x2
METADATA_FLAG_IS_REQUIRED = 0x4
def qemu_img(*args):
cmd = ['qemu-img'] + list(args)
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
def vhdx_regions(data):
out = {}
off = 0x30000
sig, _csum, count, _res = struct.unpack_from('<4sIII', data, off)
assert sig == b'regi', sig
for i in range(count):
eo = off + 16 + i * 32
guid = uuid.UUID(bytes_le=bytes(data[eo:eo + 16]))
fo, ln, _req = struct.unpack_from('<QII', data, eo + 16)
out[guid] = (fo, ln)
return out
def vhdx_metadata_items(data, region_offset):
sig, _res, count, _res2 = struct.unpack_from('<8sHHI', data, region_offset)
assert sig == b'metadata', sig
items = []
for i in range(count):
eo = region_offset + 32 + i * 32
guid = uuid.UUID(bytes_le=bytes(data[eo:eo + 16]))
ioff, ilen, flags, _res3 = struct.unpack_from('<IIII', data, eo + 16)
items.append((guid, ioff, ilen, flags, eo))
return count, items
def vhdx_data_write_guid(data):
"""The DataWriteGuid of the header with the higher sequence number."""
best = None
for off in (0x10000, 0x20000):
sig, _csum, seq = struct.unpack_from('<4sIQ', data, off)
if sig != b'head':
continue
if best is None or seq > best[0]:
best = (seq, uuid.UUID(bytes_le=data[off + 32:off + 48]))
return best[1]
def vhdx_parent_locator_item(parent_data_write_guid, relative_path, absolute_path):
"""Serialise a VHDX parent locator metadata item.
Header: 16 byte locator type GUID, 2 reserved, 2 key/value count.
Then one 12 byte entry per pair: key offset, value offset, key length,
value length -- all offsets relative to the start of the item.
Keys and values are UTF-16LE and are not NUL terminated.
"""
pairs = [
('parent_linkage', '{%s}' % parent_data_write_guid),
('relative_path', relative_path),
('absolute_win32_path', absolute_path),
]
header = struct.pack('<16sHH', PARENT_LOCATOR_TYPE.bytes_le, 0, len(pairs))
entries = bytearray()
blob = bytearray()
base = len(header) + 12 * len(pairs)
for key, value in pairs:
kb = key.encode('utf-16-le')
vb = value.encode('utf-16-le')
koff = base + len(blob)
blob += kb
voff = base + len(blob)
blob += vb
entries += struct.pack('<IIHH', koff, voff, len(kb), len(vb))
return bytes(header) + bytes(entries) + bytes(blob)
def vhdx_sector_bitmap_block(sector_numbers):
"""A 1 MiB VHDX sector bitmap block; least significant bit first."""
bitmap = bytearray(1024 * 1024)
for n in sector_numbers:
bitmap[n // 8] |= 1 << (n % 8)
return bytes(bitmap)
def patch_vhdx_child(path, parent_path, parent_relative, parent_data_write_guid,
partial_blocks, full_blocks, child_sectors):
data = bytearray(open(path, 'rb').read())
regions = vhdx_regions(data)
meta_off, meta_len = regions[REGION_METADATA]
bat_off, bat_len = regions[REGION_BAT]
count, items = vhdx_metadata_items(data, meta_off)
# 1. Set the HasParent bit in the file parameters item.
fp = [it for it in items if it[0] == META_FILE_PARAMETERS]
assert len(fp) == 1, 'expected exactly one file parameters item'
_guid, ioff, ilen, _flags, _eo = fp[0]
block_size, fp_flags = struct.unpack_from('<II', data, meta_off + ioff)
struct.pack_into('<II', data, meta_off + ioff, block_size, fp_flags | 0x2)
# 2. Append a parent locator item after the last item's data.
end = max(ioff + ilen for _g, ioff, ilen, _f, _e in items)
item_off = (end + 15) & ~15
item = vhdx_parent_locator_item(parent_data_write_guid, parent_relative, parent_path)
assert item_off + len(item) <= meta_len, 'metadata region too small'
data[meta_off + item_off:meta_off + item_off + len(item)] = item
entry_off = meta_off + 32 + count * 32
data[entry_off:entry_off + 16] = META_PARENT_LOCATOR.bytes_le
struct.pack_into('<IIII', data, entry_off + 16,
item_off, len(item), METADATA_FLAG_IS_REQUIRED, 0)
struct.pack_into('<H', data, meta_off + 10, count + 1)
# 3. Rewrite the BAT: only the blocks the child owns stay present.
nblocks = (IMAGE_SIZE + block_size - 1) // block_size
chunk_ratio = (0x800000 * 512) // block_size
for b in range(nblocks):
eo = bat_off + b * 8
(entry,) = struct.unpack_from('<Q', data, eo)
file_offset_mb = (entry >> 20) & 0xFFFFFFFFFFF
if b in partial_blocks:
state = VHDX_BAT_PARTIALLY_PRESENT
elif b in full_blocks:
state = VHDX_BAT_FULLY_PRESENT
else:
state = VHDX_BAT_NOT_PRESENT
file_offset_mb = 0
struct.pack_into('<Q', data, eo, (file_offset_mb << 20) | state)
# 4. If any block is partially present, append a sector bitmap block and
# point the chunk's sector bitmap BAT entry at it.
if partial_blocks:
claimed = []
for n in child_sectors:
if n // (block_size // SECTOR) in partial_blocks:
claimed.append(n)
sb_offset = (len(data) + 0xFFFFF) & ~0xFFFFF
data.extend(b'\x00' * (sb_offset - len(data)))
data.extend(vhdx_sector_bitmap_block(claimed))
sb_index = chunk_ratio
struct.pack_into('<Q', data, bat_off + sb_index * 8,
((sb_offset // (1024 * 1024)) << 20) | VHDX_SB_PRESENT)
open(path, 'wb').write(bytes(data))
return block_size, chunk_ratio
# --- driver -----------------------------------------------------------------
def main():
global VHD_PARENT_SECTORS, VHD_CHILD_SECTORS
parser = argparse.ArgumentParser()
parser.add_argument('outdir')
parser.add_argument('--vhd-plan', choices=sorted(VHD_SECTOR_PLANS), default='mixed')
args = parser.parse_args()
VHD_PARENT_SECTORS, VHD_CHILD_SECTORS = VHD_SECTOR_PLANS[args.vhd_plan]
out = os.path.abspath(args.outdir)
os.makedirs(out, exist_ok=True)
def p(name):
return os.path.join(out, name)
stamp = vhd_timestamp(1757030400)
# ---------------- VHD ----------------
vhd_parent_raw = build_raw(VHD_PARENT_SECTORS, 'PARENT')
vhd_child_raw = build_raw(VHD_CHILD_SECTORS, 'CHILD')
vhd_expected = bytearray(vhd_parent_raw)
for n in VHD_CHILD_SECTORS:
vhd_expected[n * SECTOR:(n + 1) * SECTOR] = marker('CHILD', n)
open(p('vhd-parent-only.raw'), 'wb').write(vhd_parent_raw)
open(p('vhd-child-only.raw'), 'wb').write(vhd_child_raw)
open(p('vhd-expected.raw'), 'wb').write(bytes(vhd_expected))
parent_uid = uuid.UUID('11111111-2222-3333-4444-555555555555').bytes
child_uid = uuid.UUID('66666666-7777-8888-9999-aaaaaaaaaaaa').bytes
write_dynamic_vhd(p('vhd-parent.vhd'), vhd_parent_raw, parent_uid, stamp)
write_differencing_vhd(p('vhd-child.vhd'), VHD_CHILD_SECTORS, IMAGE_SIZE,
child_uid, stamp, parent_uid,
parent_path=p('vhd-parent.vhd'),
parent_relative='.\\vhd-parent.vhd')
# ---------------- VHDX ----------------
vhdx_parent_raw = build_raw(VHDX_PARENT_SECTORS, 'PARENT')
vhdx_child_raw = build_raw(VHDX_CHILD_SECTORS, 'CHILD')
open(p('vhdx-parent-only.raw'), 'wb').write(vhdx_parent_raw)
open(p('vhdx-child-only.raw'), 'wb').write(vhdx_child_raw)
open(p('vhdx-parent-src.raw'), 'wb').write(vhdx_parent_raw)
open(p('vhdx-child-src.raw'), 'wb').write(vhdx_child_raw)
for name in ('vhdx-parent.vhdx', 'vhdx-child.vhdx'):
if os.path.exists(p(name)):
os.unlink(p(name))
qemu_img('convert', '-f', 'raw', '-O', 'vhdx',
'-o', 'block_size=%d,log_size=1M' % VHDX_BLOCK_SIZE,
p('vhdx-parent-src.raw'), p('vhdx-parent.vhdx'))
qemu_img('convert', '-f', 'raw', '-O', 'vhdx',
'-o', 'block_size=%d,log_size=1M' % VHDX_BLOCK_SIZE,
p('vhdx-child-src.raw'), p('vhdx-child.vhdx'))
parent_dwg = vhdx_data_write_guid(open(p('vhdx-parent.vhdx'), 'rb').read())
block_size, chunk_ratio = patch_vhdx_child(
p('vhdx-child.vhdx'),
parent_path=p('vhdx-parent.vhdx'),
parent_relative='.\\vhdx-parent.vhdx',
parent_data_write_guid=parent_dwg,
partial_blocks=set(VHDX_CHILD_BLOCKS_PARTIAL),
full_blocks=set(VHDX_CHILD_BLOCKS_FULL),
child_sectors=VHDX_CHILD_SECTORS)
# The expected composition follows straight from the BAT states above.
vhdx_expected = bytearray(IMAGE_SIZE)
spb = block_size // SECTOR
for n in range(IMAGE_SECTORS):
b = n // spb
if b in VHDX_CHILD_BLOCKS_FULL:
src = 'child-block'
elif b in VHDX_CHILD_BLOCKS_PARTIAL:
src = 'child' if n in VHDX_CHILD_SECTORS else 'parent'
else:
src = 'parent'
if src == 'child' or (src == 'child-block' and n in VHDX_CHILD_SECTORS):
vhdx_expected[n * SECTOR:(n + 1) * SECTOR] = marker('CHILD', n)
elif src == 'parent' and n in VHDX_PARENT_SECTORS:
vhdx_expected[n * SECTOR:(n + 1) * SECTOR] = marker('PARENT', n)
open(p('vhdx-expected.raw'), 'wb').write(bytes(vhdx_expected))
print('output directory: %s' % out)
print('vhd block size %d, %d blocks; child allocates blocks %s'
% (VHD_BLOCK_SIZE, IMAGE_SIZE // VHD_BLOCK_SIZE,
sorted({n // VHD_SECTORS_PER_BLOCK for n in VHD_CHILD_SECTORS})))
print('vhdx block size %d, chunk ratio %d; partial blocks %s, full blocks %s'
% (block_size, chunk_ratio, VHDX_CHILD_BLOCKS_PARTIAL, VHDX_CHILD_BLOCKS_FULL))
print('vhd parent sectors %s' % sorted(set(VHD_PARENT_SECTORS)))
print('vhd child sectors %s' % sorted(set(VHD_CHILD_SECTORS)))
print('vhdx parent sectors %s' % sorted(set(VHDX_PARENT_SECTORS)))
print('vhdx child sectors %s' % sorted(set(VHDX_CHILD_SECTORS)))
for name in sorted(os.listdir(out)):
print(' %-24s %d' % (name, os.path.getsize(p(name))))
return 0
if __name__ == '__main__':
sys.exit(main())
The composer it is scored with:
"""Compose a libvhdi chain to a raw file.
usage: compose.py OUT CHILD [PARENT [GRANDPARENT ...]]
If only CHILD is given, no parent is attached (child-only read).
"""
import sys
import pyvhdi
out_path = sys.argv[1]
paths = sys.argv[2:]
files = []
for p in paths:
f = pyvhdi.file()
f.open(p, 'r')
files.append(f)
for i in range(len(files) - 1):
files[i].set_parent(files[i + 1])
top = files[0]
size = top.get_media_size()
sys.stderr.write('media_size=%d disk_type=%d\n' % (size, top.get_disk_type()))
try:
sys.stderr.write('parent_identifier=%s\n' % top.get_parent_identifier())
sys.stderr.write('parent_filename=%s\n' % top.get_parent_filename())
except Exception as exc: # noqa: BLE001
sys.stderr.write('no parent info: %s\n' % exc)
top.seek(0)
with open(out_path, 'wb') as fh:
remaining = size
while remaining > 0:
chunk = top.read(min(1024 * 1024, remaining))
if not chunk:
raise SystemExit('short read with %d bytes remaining' % remaining)
fh.write(chunk)
remaining -= len(chunk)
sys.stderr.write('wrote %d bytes to %s\n' % (size, out_path))
Back brief¶
Before executing any step, back brief the operator on the understanding of this phase and how the intended work aligns with it. Step 1a additionally reports its go/no-go to the management session before step 1b begins: the verdict changes the shape of three later phases, and it is cheap to hear early and expensive to discover late.