diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dca1ca084..e0e612c33 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -392,7 +392,7 @@ jobs:
env:
RUST_BACKTRACE: 1
RUST_LIB_BACKTRACE: 1
- run: cargo test -p win-durable-ioring --locked --lib -- --include-ignored an_unbuffered_file_is_refused_on_a_remote_device
+ run: cargo test -p win-durable-ioring --locked --lib -- --include-ignored an_unbuffered_file_is_refused_on_a_remote_device a_file_on_a_share_has_no_domains_found
# A raw volume against a real one: a scratch virtual disk, created and attached with diskpart,
# which the runner's administrator account can do and a developer's usually cannot (DI-D-61).
# Nothing here is real storage; each test detaches and deletes its own disk. A failure here is
@@ -406,7 +406,7 @@ jobs:
env:
RUST_BACKTRACE: 1
RUST_LIB_BACKTRACE: 1
- run: cargo test -p win-durable-ioring --locked --lib -- --include-ignored raw_volume raw_disk
+ run: cargo test -p win-durable-ioring --locked --lib -- --include-ignored raw_volume raw_disk a_file_on_a_virtual_disk
# The ETW provider wrapper against real trace sessions (WE-D-3): each test starts a session
# that logs to a file, enables a provider in it, and reads the file back. Starting a session
# needs an elevated process or the Performance Log Users group, which the runner's
diff --git a/CHECKLIST.md b/CHECKLIST.md
index def57f314..56bb11f66 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -490,79 +490,6 @@ Historical means `COMPLETED-*` archives, `design-sessions/`, measurement capture
and the root [DESIGN-NOTES.md](DESIGN-NOTES.md). Commit as `docs`, one commit per crate so each
scope stays accurate.
-## M39 -- A file's flush domains, derived when it is opened through the namespace route
-
-Opened 2026-10-06 from the durable-ioring design session
-([DESIGN-SESSION-2026-10-05-epoch-ring.md](design-sessions/DESIGN-SESSION-2026-10-05-epoch-ring.md)) as a free-standing
-locality helper; **re-scoped 2026-11-05** by the engineer. durable-ioring takes, per file, a set of opaque
-**flush-domain** keys ([DI-D-21](crates/durable-ioring/DESIGN-NOTES.md#di-d-21)) and never looks at Windows' storage
-stack itself. Everything that consumes the keys is built: declarations, failure import, and the routing provider
-([DI-12.2](crates/durable-ioring/COMPLETED-CHECKLIST.md#di-122)). `win-durable-ioring` already derives them for a raw
-volume or disk from its extents, as `disk_flush_domain` ([DI-D-61](crates/durable-ioring/DESIGN-NOTES.md#di-d-61)); for a
-file it derives none, and its documentation says a file's domains "are what it cannot know", so a consumer declares them
-with `with_domains`.
-
-**The scope, in the engineer's words (2026-11-05):** "this isn't saying that durable ioring only supports this but that
-our assistance for computing domains comes when you use the namespace crate. This way we have the namespace available to
-use namespace based OS functions as well as the handle based ones. Advanced users who want to hand handles to the durable
-ioring directly, I will assume are advanced enough to also manage their domains directly." So the derivation belongs to
-`win-durable-ioring`'s open, which goes through `windows-namespace-request-sys`' `OpenFile` and has both the name and the
-handle in `OpenRequest::perform`; a target made any other way declares its own, as today. That settles the old `M39.1`
-(where the helper lives) and drops its facts-or-policy question: the policy is `win-durable-ioring`'s, as it is for a raw
-volume, and each OS call stays one entry in the namespace or volume crate.
-
-The keys are the identifier's **exact bytes**, interned by dioring (the engineer, 2026-10-06, under
-[DI-2.10](crates/durable-ioring/COMPLETED-CHECKLIST.md)); a file's local keys are the same `disk_flush_domain` bytes a
-raw volume derives, so a file and a volume on one disk share fate by construction.
-
-- [ ] **M39.1** -- **Record the decision, and settle its two open choices.** A decision in durable-ioring's
- [DESIGN-NOTES.md](crates/durable-ioring/DESIGN-NOTES.md), amending what `win-durable-ioring` says a file can know and
- what [API.md](crates/durable-ioring/API.md) section 13 says ("a locality helper's job outside this crate"), with the
- engineer's ruling quoted. The choices: whether a file's domains are derived at the open (every open pays for a few
- queries, one of them on the volume) or on first request; and whether an open by identifier (`OpenFileById`), which has
- a hint handle and no name, gets the handle-based half or none.
-
-- [ ] **M39.2** -- **Derive a file's found domains at its open, all or nothing.**
-
- - [ ] **M39.2.1** -- **Wrap the name-based calls the derivation needs**, as entries in
- `windows-namespace-request-sys`, which exists for synchronous namespace calls that can block on the network. Check
- each against its page first and record which were checked. Candidates, none yet confirmed for this use:
- `DfsGetClientInfo` (a DFS link to the target it is using), `GetDriveTypeW` (a removable or RAM disk, which no
- handle-based documented call reports), and `GetVolumePathNameW` with `GetVolumeNameForVolumeMountPointW` (the volume a
- name resolves to, for the prediction of `M39.3`). Anything the handle answers is asked of the handle.
-
- - [ ] **M39.2.2** -- **A local file's domains.** The handle's final path in `VOLUME_NAME_GUID` form names its
- volume; the volume, opened with no access as `win-volume-sys`' tests do, gives its disk extents; each disk is
- `disk_flush_domain`. A disk that is a virtual disk is followed through its storage dependency to the volume holding
- its backing file, and on from there. No real dependency entry has been observed yet, because attaching a virtual disk
- needs an elevated token ([WV-D-10](crates/win-volume-sys/DESIGN-NOTES.md#wv-d-10)), so that hop's test needs a
- privileged run or a stated gap.
-
- - [ ] **M39.2.3** -- **A remote file's domain.** The server, from the handle's final path, which the open already reads
- to refuse a remote device for an unbuffered file and which follows mapped drives and links; a DFS link resolved
- through its name. Keyed by server and not by share, since two shares on one server sit on disks this cannot see and
- the coarser key is the safe one.
-
- - [ ] **M39.2.4** -- **All or nothing, and what is left to the consumer.** Any hop that fails leaves the file with no
- found domains, which is unknown and shares fate with everything: the safe default, never a partial set, which is the
- one way a declaration makes the contract false. `with_domains` stays, adding what Windows does not show, and its
- documentation names it: the disks of a Storage Spaces pool (reachable only through the Storage Management API's WMI
- classes), the disks behind a hardware RAID controller, and a cache a controller shares between disks.
-
-- [ ] **M39.3** -- **Predict a directory's domains before a file is created, and confirm them at the open.** A consumer
- presents storage roots as directories and wants to know, before creating files, where they will land: the
- directory's volume, mount points included, through the name-based calls of `M39.2.1`. Its path is resolved again at
- each create, so a junction swapped between the two sends the file elsewhere; the open's own derivation is what the
- file's declaration uses, so a swap cannot make it wrong, and the prediction is compared with it and a mismatch
- reported. An open relative to the directory's handle would close the race, and is parked as
- [DI-15.3](crates/durable-ioring/PARKED.md) for want of a documented call. From
- [the design session](design-sessions/DESIGN-SESSION-2026-10-21-router-domains-and-paired-events.md#the-real-world-model);
- the engineer, 2026-10-21: "The volume id refinement is a good catch for that race condition."
-
-- [ ] **M39.4** -- **Show it end to end, in the shape of the working model:** a data directory and a log on separate
- disks, files opened through the namespace route with their domains derived, and a failed flush on one shown not to
- make the other's writes suspect. Coarse on purpose: whether that can be shown without hardware that fails on demand is
- settled once `M39.2` is built.
## M42 -- Apply the documented-contract rule to the refusals that already exist
The rule is in [DESIGN-NOTES.md](DESIGN-NOTES.md#refusals-rest-on-the-documented-contract) (the engineer,
diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md
index cc19c9e26..eb8568a72 100644
--- a/COMPLETED-CHECKLIST.md
+++ b/COMPLETED-CHECKLIST.md
@@ -4532,4 +4532,141 @@ moves to a long-haul tier that runs daily. Tests that have never run anywhere st
Its items, each archived under its own heading above: [M45.1](#m451), [M45.2](#m452), [M45.3](#m453), [M45.4](#m454),
[M45.5](#m455), [M45.6](#m456), [M45.7](#m457), [M45.8](#m458) (and [M45.8.1](#m4581), withdrawn), [M45.9](#m459) and
[M45.10](#m4510). The work this left waiting on a run on a runner is parked as `M45+.1` and `M45+.2` in
-[PARKED.md](PARKED.md).
\ No newline at end of file
+[PARKED.md](PARKED.md).
+
+## Moved 2026-11-05 16:20:42 -05:00 -- M39.1: when a file's flush domains are derived
+
+### M39.1 -- A file opened through the namespace route has its flush domains derived at the open, all or nothing; an opt-out's shape is reserved and not built. *(completed 2026-11-05 16:20:42 -05:00)*
+
+The decision is [DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117). Both choices the item named were settled by the engineer, 2026-11-05: at the open ("I would think that the data would be determined at open"), which the investigation supported on the core's interface, where blocking work belongs, and the raw volume's precedent; and an open by identifier gets the whole derivation, since every step reads the handle. An opt-out was weighed and its shape reserved without building it ("Reserve the shape but we don't need to implement it today."). The item as written:
+
+- [x] **M39.1** -- **Record the decision, and settle its two open choices.** A decision in durable-ioring's
+ [DESIGN-NOTES.md](crates/durable-ioring/DESIGN-NOTES.md), amending what `win-durable-ioring` says a file can know and
+ what [API.md](crates/durable-ioring/API.md) section 13 says ("a locality helper's job outside this crate"), with the
+ engineer's ruling quoted. The choices: whether a file's domains are derived at the open (every open pays for a few
+ queries, one of them on the volume) or on first request; and whether an open by identifier (`OpenFileById`), which has
+ a hint handle and no name, gets the handle-based half or none.
+
+## Moved 2026-11-05 16:23:59 -05:00 -- M39.2.1: the name-based calls the derivation needs
+
+### M39.2.1 -- The derivation needs none of the name-based candidates; `NetDfsGetClientInfo` moves to `M39.2.3` and the volume-path calls to `M39.3`. *(completed 2026-11-05 16:23:59 -05:00)*
+
+Each candidate's page was read on 2026-11-05.
+
+- `GetDriveTypeW`: reports a removable, fixed, CD-ROM, RAM or network drive for a root path. Nothing in the derivation acts on the kind, and a device that does not answer the extents query already leaves a file unknown ([DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117)), so it is not wrapped.
+- `GetVolumePathNameW` with `GetVolumeNameForVolumeMountPointW`: resolve a name to the mount point its end lies under, and that to a volume. The derivation reads the opened handle, so `M39.2` does not need them; `M39.3`'s prediction before a file exists may, and the item now names them beside the alternative of deriving from the directory's own handle.
+- `NetDfsGetClientInfo` (the plan called it `DfsGetClientInfo`; this is the documented name): reads the DFS client's cache for a DFS root or link's UNC path, needs no special group membership, and levels 3 and 4 report the targets. Needed only if `M39.2.3` keys a remote file by its server, so the item now carries it.
+
+So nothing is wrapped here. The item as written:
+
+- [x] **M39.2.1** -- **Wrap the name-based calls the derivation needs**, as entries in
+ `windows-namespace-request-sys`, which exists for synchronous namespace calls that can block on the network. Check
+ each against its page first and record which were checked. Candidates, none yet confirmed for this use:
+ `DfsGetClientInfo` (a DFS link to the target it is using), `GetDriveTypeW` (a removable or RAM disk, which no
+ handle-based documented call reports), and `GetVolumePathNameW` with `GetVolumeNameForVolumeMountPointW` (the volume a
+ name resolves to, for the prediction of `M39.3`). Anything the handle answers is asked of the handle.
+
+## Moved 2026-11-05 16:49:13 -05:00 -- M39.2.2: a local file's flush domains
+
+### M39.2.2 -- A file `win-durable-ioring` opens has its volume's disks found as its flush domains, a virtual disk followed by its bus type, all or nothing. *(completed 2026-11-05 16:49:13 -05:00)*
+
+The decision is [DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117), which now also records that a virtual disk is decided by its bus type and not by the dependency query's error.
+
+- `win-volume-sys` gained `storage_device_descriptor` and `StorageBusType` (`StorageDeviceProperty`, one function per property by [WV-D-9](crates/win-volume-sys/DESIGN-NOTES.md#wv-d-9)), with seven sabotage entries, all caught. The `STORAGE_BUS_TYPE` page lists `BusTypeNvmeof` as 20, which `windows-sys` 0.61.2 does not declare.
+- `win-durable-ioring`'s `file_domains` walks a storage stack behind a trait: the file's volume from its final path in GUID form, the volume's disks from `OpenedVolume` with no data access, a disk's bus type, and a file-backed disk's host volumes. `OpenedFile::admit` keeps what it finds, `OpenedFile::flush_domains` reports it, and `Win32FileTarget::new` carries it as `from_volume` carries a volume's. The bound on volumes followed counts volumes as followed, after a sabotage that skipped the visited check was caught only by hanging.
+- Tests: the rule against a stack in memory, in both directions (virtual disks nested, several hosts, a loop, the bound and one short of it, and a failure at each step leaving no domains); a local file against what its volume derives; a file opened in either mode carrying its domains into its target; a declaration adding to what was found; a file over `\\localhost\C$` finding none (ignored for the administrative share, and added to the CI step that runs this crate's share test); and a scratch virtual disk, through the step that already attaches one for the raw-volume tests, observing for the first time whether a VHD's disk reports `BusTypeFileBackedVirtual` and whether its dependency names the temporary directory's volume. That last one needs an administrator and has not run on this machine.
+- Four tests restated the old rule (a file target's domains are only what was declared) and now state the new one. The `dioring` tests use temporary files, all on one disk, to stand for files on separate devices; their `target` helper sets the found disk aside through a test-only `without_found_domains`. The public opt-out stays reserved and unbuilt, as DI-D-117 records.
+- Nine sabotage entries for the derivation and one control that survives, and two older entries repointed at the reshaped `Win32FileTarget::new`; all behave as declared.
+
+The item as written:
+
+- [x] **M39.2.2** -- **A local file's domains, all or nothing, with the texts that change with them** (absorbs what
+ was `M39.2.4`, which could not land apart: the first code that derives a domain is what makes the old texts false).
+ The handle's final path in `VOLUME_NAME_GUID` form names its volume; the volume, opened with no access as
+ `win-volume-sys`' tests do, gives its disk extents; each disk is `disk_flush_domain`. A disk whose bus type is
+ `BusTypeFileBackedVirtual` (`STORAGE_DEVICE_DESCRIPTOR`, read through a new `win-volume-sys` entry, one function per
+ property by [WV-D-9](crates/win-volume-sys/DESIGN-NOTES.md#wv-d-9)) is followed through its storage dependency to
+ the volume holding its backing file, and on from there. The bus type decides it, and not the dependency query's
+ error: [the `GetStorageDependencyInformation` page](https://learn.microsoft.com/en-us/windows/win32/api/virtdisk/nf-virtdisk-getstoragedependencyinformation)
+ (read 2026-11-05) names no error for a volume that is not on a virtual disk, so `0xC03A0015` is a measurement
+ ([WV-D-10](crates/win-volume-sys/DESIGN-NOTES.md#wv-d-10)) and nothing is built on it. No real dependency entry has
+ been observed, because attaching a virtual disk needs an elevated token, so that hop's test needs a privileged run
+ or a stated gap. Any hop that fails leaves the file with no found domains, which is unknown and shares fate with
+ everything; never a partial set. A remote file's final path has no volume GUID, so until `M39.2.3` it is unknown
+ the same way. `with_domains` stays, adding what Windows does not show, and its documentation names it: the disks of
+ a Storage Spaces pool, the disks behind a hardware RAID controller, and a cache a controller shares between disks.
+ The texts [DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117) amends change here: the file target's "are
+ what it cannot know", and [API.md](crates/durable-ioring/API.md) section 13's "a locality helper's job". Two commits,
+ since two crates' code changes: the `win-volume-sys` entry, then the derivation.
+
+## Moved 2026-11-05 17:06:22 -05:00 -- M39.2.3: a remote file's domain
+
+### M39.2.3 -- A file on a share is unknown: no key is derived for it, and a consumer that knows its storage declares it. *(completed 2026-11-05 17:06:22 -05:00)*
+
+The item as planned keyed a remote file by its server. Implementing it showed that unsafe: a server's name is not its identity, servers in a cluster share disks, a DFS link hides its target, and a share can be this machine's own disk, which `\\localhost\C$` showed reports as UNC and which the documented loopback flag is not documented to catch by every name. Each gives two files that share fate different keys, a missing domain. The engineer chose among leaving it unknown, one key for every remote file, and a key per server, 2026-11-05: "Go with a then", after the assistant corrected the reading that unknown keeps files apart (it shares fate with everything, the coarsest grouping). Recorded in [DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117).
+
+No code changed for the behaviour: a remote handle's final path names no volume GUID, so `M39.2.2`'s derivation already found none. `Win32FileTarget`'s documentation now says no key is derived for a share and that, with none found, what a consumer declares is the file's whole set; the loopback test asserts that. `NetDfsGetClientInfo` is not wrapped, since nothing keys by server.
+
+The item as written:
+
+- [x] **M39.2.3** -- **A remote file's domain.** The server, from the handle's final path, which the open already reads
+ to refuse a remote device for an unbuffered file and which follows mapped drives and links; a DFS link resolved
+ through `NetDfsGetClientInfo`, wrapped as an entry in `windows-namespace-request-sys` if this item keys by server
+ (read 2026-11-05: it reads the DFS client's cache for a DFS root or link's UNC path, needs no special group
+ membership, and its levels 3 and 4 report the targets). Keyed by server and not by share, since two shares on one server sit on disks this cannot see and
+ the coarser key is the safe one.
+
+## Moved 2026-11-05 17:06:51 -05:00 -- M39.2: a file's flush domains, derived at its open
+
+### M39.2 -- A file `win-durable-ioring` opens has its flush domains derived at the open, all or nothing; a file on a share is unknown. *(completed 2026-11-05 17:06:51 -05:00)*
+
+Its steps are archived above: [M39.2.1](#m3921) (no name-based call is needed), [M39.2.2](#m3922) (a local file's domains, with `M39.2.4` folded in) and [M39.2.3](#m3923) (a file on a share is unknown). The decision is [DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117). The item as written:
+
+- [x] **M39.2** -- **Derive a file's found domains at its open, all or nothing**, in `OpenedFile::admit`, for opens by path
+ and by identifier ([DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117)).
+
+## Moved 2026-11-05 17:36:55 -05:00 -- M39.3: predicting a directory's domains
+
+### M39.3 -- `DirectoryDomains::predict` derives a directory's domains from its own handle, and `confirm` checks a file's against them. *(completed 2026-11-05 17:36:55 -05:00)*
+
+The decision is [DI-D-118](crates/durable-ioring/DESIGN-NOTES.md#di-d-118): the engineer chose the directory's own handle over resolving its name (2026-11-05, "yes option 1"). `win-durable-ioring`'s `directory_domains` opens the directory through the namespace crate's `OpenFile` with no access and backup semantics, reusing `open_request` (now visible within the backend), and runs `file_domains::derive` on the handle. `confirm` returns a `DomainMismatch` carrying both sides. Tests: a directory predicts its volume's disks, a file created in it confirms, a differing prediction and one with an extra domain are mismatches, a missing directory is the open's own `NotFound`, a failing stack is an error and not an empty set, and over the loopback share a local prediction does not confirm the file and a shared directory is not predicted (ignored for the share; named so the existing CI step runs it). Measured on the way: a remote handle's final path in GUID form is refused by `GetFinalPathNameByHandleW` with `ERROR_PATH_NOT_FOUND`. Four sabotage entries, all caught. `GetVolumePathNameW` and `GetVolumeNameForVolumeMountPointW` are not wrapped.
+
+The item as written:
+
+- [x] **M39.3** -- **Predict a directory's domains before a file is created, and confirm them at the open.** A consumer
+ presents storage roots as directories and wants to know, before creating files, where they will land: the
+ directory's volume, mount points included: through `GetVolumePathNameW` and `GetVolumeNameForVolumeMountPointW`
+ (read 2026-11-05; the first resolves a name to the mount point its end lies under), or through the derivation of
+ `M39.2` from the directory's own handle, opened through the route; which is this item's to settle. Its path is resolved again at
+ each create, so a junction swapped between the two sends the file elsewhere; the open's own derivation is what the
+ file's declaration uses, so a swap cannot make it wrong, and the prediction is compared with it and a mismatch
+ reported. An open relative to the directory's handle would close the race, and is parked as
+ [DI-15.3](crates/durable-ioring/PARKED.md) for want of a documented call. From
+ [the design session](design-sessions/DESIGN-SESSION-2026-10-21-router-domains-and-paired-events.md#the-real-world-model);
+ the engineer, 2026-10-21: "The volume id refinement is a good catch for that race condition."
+
+## Moved 2026-11-05 19:13:58 -05:00 -- M39.4: the working model end to end
+
+### M39.4 -- The working model end to end: a failure of the data file's storage reaches the log exactly when their derived domains share a disk. *(completed 2026-11-05 19:13:58 -05:00)*
+
+Built as the engineer agreed (2026-11-05, "implement m39.4 in the way you suggest"), in `win-durable-ioring`'s `dioring/tests/working_model.rs`:
+
+- **On any machine:** a data directory under the temporary directory and a log directory under `RUNNER_TEMP` when set, otherwise also under the temporary directory. Each directory is predicted and its file confirmed (DI-D-118), both files are opened through the route with their domains derived (DI-D-117), each gets one write, and a failure imported over the data file's derived domains is checked: the data write is suspect, and the log's exactly when the two files' domains share a disk. It prints which case ran. Here it was the shared case (one disk); nothing guarantees the separate case runs anywhere. An imported failure stands in for a failed flush because, in a seal that flushes both files, the order in which the two flushes complete decides whether the log's write was already covered, and an import has no order.
+- **On CI only:** a file on a formatted scratch virtual disk (`ScratchVolume::create_formatted`, new, with its script tested) and a file in the temporary directory, which holds the disk's backing file. The virtual disk's file has the host's domains and its own disk besides; a failure of the host's disks reaches both writes, and one of the virtual disk's own disk reaches only its file. It needs an administrator, so it is ignored and runs in the CI step that already attaches scratch disks, whose name filter now includes it. It has not run on this machine.
+
+No sabotage entry was added: the entries for the derivation (an open that finds nothing, a target that does not carry what was found) are also caught by the end-to-end test.
+
+The item as written:
+
+- [x] **M39.4** -- **Show it end to end, in the shape of the working model:** a data directory and a log on separate
+ disks, files opened through the namespace route with their domains derived, and a failed flush on one shown not to
+ make the other's writes suspect. Coarse on purpose: whether that can be shown without hardware that fails on demand is
+ settled once `M39.2` is built.
+
+## Moved 2026-11-05 19:14:25 -05:00 -- M39, a file's flush domains derived at its open
+
+### M39 -- A file's flush domains, derived when it is opened through the namespace route. *(completed 2026-11-05)*
+
+Opened 2026-10-06 as a free-standing locality helper and re-scoped by the engineer on 2026-11-05: help computing flush domains comes when a file is opened through the namespace crate, and a consumer handing handles to the durable ioring directly manages its own. A file `win-durable-ioring` opens now has its volume's disks found as its flush domains, the same keys a raw volume derives, with a virtual disk followed by its bus type, all or nothing; a file on a share is unknown; and a directory's domains can be predicted from its own handle and a file's confirmed against them. The decisions are [DI-D-117](crates/durable-ioring/DESIGN-NOTES.md#di-d-117) and [DI-D-118](crates/durable-ioring/DESIGN-NOTES.md#di-d-118); a dropped proposal is [WV-D-13](crates/win-volume-sys/DESIGN-NOTES.md#wv-d-13).
+
+Its items, each archived under its own heading above: [M39.1](#m391), [M39.2](#m392) with [M39.2.1](#m3921), [M39.2.2](#m3922) and [M39.2.3](#m3923), [M39.3](#m393) and [M39.4](#m394).
\ No newline at end of file
diff --git a/PLANS.md b/PLANS.md
index 3e2c604bb..8030d7d74 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -40,7 +40,7 @@ plans tracker: [crates/durable-ioring/PLANS.md](crates/durable-ioring/PLANS.md),
| [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) |
| [crates/topology-planner/CHECKLIST.md](crates/topology-planner/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a **dataflow description of an application** -- its input, output and processing code paths, the shape settled 2026-09-23 as `EP-D-6` and discharging the deferral `EP-D-4` named -- plus an abstracted idealized machine description, to **one or more suggested** sets of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. `EP-D-6` also split planning into two stages -- connectivity inferred with no machine in hand, then realization against a machine model -- which adds `EP-1+.5` (the connectivity graph is a value and needs a type and a home) and `EP-1+.6` (the answer is a set, so what a caller receives and what `M2+.2` renders are sets). Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 was a *requirements* milestone -- it states what the topology must answer, and it fed the locality-model session, which has since concluded as `D-13`..`D-21`. **The component was deferred past PR #56 by direction**, contributing only planning documents there; #56 then closed unmerged on 2026-09-15 and its content is landing in peeled pieces instead, so the component is still unlanded and goes in its own pull request. Per `D-21` the topology reshape lands without it, since `windows-topology-sys` publishes a refined view of what the platform publishes and an adapter absorbs the rest. M2+ and M3+ are parked on that session concluding, and are additionally **awaiting a re-cut**: EP-D-4 and EP-D-5 re-scoped the component into four parts (`topology-model` holding the abstract machine description, the planner's traits and the plan type; `topology-planner`; an inward Windows adapter; an outward realizer), and only M1 has been reconciled with that. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/topology-planner/DESIGN-NOTES.md](crates/topology-planner/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) |
| [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | **M30 is complete and archived** in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md): the queue crate's name, skeleton and SPSC shape. M31 built the bounded-array MPSC (with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15) and is done but for `M31.6`, the `loom` verification, which is re-homed as `M30.4` in [CHECKLIST.md](CHECKLIST.md). M32 remains: the contract decisions -- ordering, correlation, backpressure among them -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, and the client-side `ThreadpoolWait` fan-in helper; the durability crate it also parked (`M33+.5`) was transferred on 2026-10-05 to [crates/durable-ioring](crates/durable-ioring/COMPONENT.md), reshaped to build on `IoRing` directly. M-inf holds items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) |
-| [CHECKLIST.md](CHECKLIST.md) | in progress | M42: apply the documented-contract rule to the refusals made ahead of platform calls that already exist. M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive the completed item bodies still carried by the three root checklists) are open. M37: discharge the failable-call standard across the workspace. M30: find out how much of this workspace's algorithm correctness can be machine-checked -- a survey matching each argued-but-unchecked algorithm to a class of tool (TLA+/PlusCal, loom, bounded proof, `const` assertions), one pilot chosen because parameter shrinking makes an untestable property exhaustive, and a named list of what the pilot could not reach, which is the deliverable. Scoped as an instrument for narrowing hand-inspection rather than replacing it, and explicitly not a reversal of [D-31](crates/windows-waitable-queues/DESIGN-NOTES.md#d-31). Also re-homes `M31.6`, the `loom` verification the queue crate promises adopters before 1.0: it was previously untracked, referenced from that crate's design notes, a source file and its sabotage manifest with no live checklist item anywhere, and is now queued as M30.4. M30's rationale is in [DESIGN-RATIONALE.md](DESIGN-RATIONALE.md#machine-checking-what-is-argued) -- Tier 2, because no decision is taken yet; M30.5 is what produces one. M38 labels Model A and Model B at their first use in live files. M39 derives a file's flush domains when it is opened through the namespace route (re-scoped 2026-11-05: a consumer handing handles directly declares its own). M43 corrects the `durable-ioring-events` 0.1.1 release, which should have been 0.2.0: release it, then yank 0.1.1 and `win-durable-ioring` 0.5.0 ([DE-D-8](crates/durable-ioring-events/DESIGN-NOTES.md#de-d-8)). | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) for M19-M21 and M37; N/A for M30 |
+| [CHECKLIST.md](CHECKLIST.md) | in progress | M42: apply the documented-contract rule to the refusals made ahead of platform calls that already exist. M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive the completed item bodies still carried by the three root checklists) are open. M37: discharge the failable-call standard across the workspace. M30: find out how much of this workspace's algorithm correctness can be machine-checked -- a survey matching each argued-but-unchecked algorithm to a class of tool (TLA+/PlusCal, loom, bounded proof, `const` assertions), one pilot chosen because parameter shrinking makes an untestable property exhaustive, and a named list of what the pilot could not reach, which is the deliverable. Scoped as an instrument for narrowing hand-inspection rather than replacing it, and explicitly not a reversal of [D-31](crates/windows-waitable-queues/DESIGN-NOTES.md#d-31). Also re-homes `M31.6`, the `loom` verification the queue crate promises adopters before 1.0: it was previously untracked, referenced from that crate's design notes, a source file and its sabotage manifest with no live checklist item anywhere, and is now queued as M30.4. M30's rationale is in [DESIGN-RATIONALE.md](DESIGN-RATIONALE.md#machine-checking-what-is-argued) -- Tier 2, because no decision is taken yet; M30.5 is what produces one. M38 labels Model A and Model B at their first use in live files. M43 corrects the `durable-ioring-events` 0.1.1 release, which should have been 0.2.0: release it, then yank 0.1.1 and `win-durable-ioring` 0.5.0 ([DE-D-8](crates/durable-ioring-events/DESIGN-NOTES.md#de-d-8)). | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) for M19-M21 and M37; N/A for M30 |
| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. **Both reached crates.io on 2026-09-05**, by a route this file does not describe, since PR #56 closed unmerged; `SH-4.15` owns reconciling M4 with what shipped. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work gated the merge, and has since discharged**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerned the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. It became the `MMT-*` plan, which has landed; the session has concluded and 0.2.0 shipped the new model, so M3 no longer waits on M16. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) |
| [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small Windows tool a stranger can download, run once, and send back one structured result -- distributed as a GitHub release binary, never to a registry, per `PT-5.6` -- so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **M1-M4 and M36 are complete and archived** in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md): the name and what the record says about a machine, `(group, number)` processor identity so a machine with more than 64 of them is not silently miscounted, each NUMA hop measured in both directions with the ring placed deliberately, the move of the measurement modules, a schema-versioned submission record, the runner's experience and trust, and redaction of the secondary metadata by default. What remains is M5 (distribute the binary), M6 (are "equivalent" processors actually equivalent?) and M7 (report what Windows contradicts about itself). **M5+ is WITHDRAWN**: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, so it needs nothing published, and an earlier version of this row wrongly gated the whole tool on releasing `windows-topology-sys` and `windows-waitable-queues`. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) |
| [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | **M22-M29 are complete and archived** in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md): `windows-thread-ambient-sys` (a standalone layer that captures a thread's ambient state and applies it on another thread), `windows-namespace-request-sys` (marshalable Win32 namespace call parameter sets, over an entry list audited from three real consumers rather than guessed), `windows-platform-probes` (a durable home for the measurements this workspace's designs rest on), and the defects the audit of those three found. What remains is **pending**: the three `M26+` items were each gated on the namespace-facility design branch reaching `main`, and it has, so the gate has lifted -- reconciling the imported design background, applying the M22.2 narrowing to M21.2, and making the merge-or-delete decision on the duplicated path preparation. The file is deleted outright once those land. | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) |
diff --git a/crates/durable-ioring/API.md b/crates/durable-ioring/API.md
index be4113502..af9b7a55b 100644
--- a/crates/durable-ioring/API.md
+++ b/crates/durable-ioring/API.md
@@ -288,7 +288,8 @@ been given, in the order named, once the import is admitted, so the import alloc
Declarations must be complete: extra domains are safe, a missing one is the one way a declaration
can make the contract false ([CONTRACT.md](CONTRACT.md)). Deriving the bytes from Windows' storage
-topology is a locality helper's job outside this crate (root `M39`).
+topology is outside this crate: `win-durable-ioring` derives a file's from its handle when it opens it, and a raw
+volume's from its disks ([DI-D-117](DESIGN-NOTES.md#di-d-117)); a target made any other way declares its own.
### 14. Ending an instance
diff --git a/crates/durable-ioring/DESIGN-NOTES.md b/crates/durable-ioring/DESIGN-NOTES.md
index dcfae7dcb..4d97b0c14 100644
--- a/crates/durable-ioring/DESIGN-NOTES.md
+++ b/crates/durable-ioring/DESIGN-NOTES.md
@@ -1416,7 +1416,7 @@ reports no timing, and nothing else: a domain it does not route is panicked on,
cannot narrow a provider to a subset of what it declares, and cannot name a domain the provider does not.
**Why.** A flush domain is the consumer's exact bytes ([DI-D-21](#di-d-21)), minted by the layer that maps the consumer's
-storage onto domains (root [M39](../../CHECKLIST.md)). Whatever builds a provider must be handed those same bytes, so it
+storage onto domains (root [M39](../../COMPLETED-CHECKLIST.md#m39)). Whatever builds a provider must be handed those same bytes, so it
knows what the provider serves when it builds it, and a provider built with its domains says so through `domains()`. A
second list at registration would be a second statement of the same fact, free to disagree with the first. Narrowing a
provider, or renaming domains for a provider whose identifiers differ from the consumer's, can be added later as another
@@ -2869,3 +2869,87 @@ sample does not attempt `no_std`.
**Scheduled work:** `DI-M24` and `DI-21.3` to `DI-21.8`, with `DI-21.5.1` to `DI-21.5.3` for reservations, reading by LSN
and shared clients, in [CHECKLIST.md](CHECKLIST.md), and `DI-21+.2` in [PARKED.md](PARKED.md).
+
+## DI-D-117: a file `win-durable-ioring` opens has its flush domains derived at the open, all or nothing
+
+**Decided by the engineer, 2026-11-05 (root [M39.1](../../COMPLETED-CHECKLIST.md#m391)), from the assistant's
+investigation.** The scope, in the engineer's words: "this isn't saying that durable ioring only supports this but that our
+assistance for computing domains comes when you use the namespace crate. [...] Advanced users who want to hand handles to
+the durable ioring directly, I will assume are advanced enough to also manage their domains directly." Asked when the
+domains are found: "I would think that the data would be determined at open".
+
+- **Where.** In `OpenedFile::admit`, the checks every open makes on the handle it opened, so an open by path and an open by
+ identifier ([DI-D-70](#di-d-70)) both derive them, and `Win32FileTarget::new` carries them as `from_volume` carries a
+ volume's ([DI-D-61](#di-d-61)). A target made any other way has none found and declares its own with `with_domains`, as
+ before. The steps are root [M39.2](../../COMPLETED-CHECKLIST.md#m392)'s; a local file's domains are the same `disk_flush_domain` bytes a
+ raw volume derives, so a file and a volume on one disk share fate by construction.
+- **All or nothing.** A hop that fails leaves the file with no found domains, which is unknown and shares fate with every
+ target: the safe default. A partial set is never produced, since a missing domain is the one way a declaration makes the
+ contract false ([DI-D-21](#di-d-21)).
+- **Why at the open and not on first request.** `IoTarget::flush_domains` is an accessor the core reads once, in
+ `describe`, when it adds the target; deriving there would put blocking device I/O, with no error to report, in the core's
+ add path. The open is the call a consumer already arranges to run where blocking is acceptable, and target construction
+ does no I/O. `OpenedVolume::open` already reads its extents at the open.
+- **By identifier, all of it.** Every step reads the handle: its final path, its volume, the volume's disks, a remote
+ file's server. The caller's name is needed only for root `M39.3`'s prediction before a file exists, and possibly for a DFS
+ link, which root `M39.2.1` checks.
+- **A snapshot, stated.** The domains are those of the moment of the open. A volume later extended onto another disk is not
+ seen; deriving later would not see it either.
+- **A file on a share is unknown, and no key is derived for it.** The engineer, 2026-11-05, choosing among leaving it
+ unknown, one key for every remote file, and a key per server: "Go with a then". A server's name is not its identity
+ (one machine answers to several names), servers in a cluster share disks, a DFS link hides its target, and a share
+ can be this machine's own disk, which the documented loopback flag is not documented to catch by every name. Any key
+ would let two files that share fate carry different keys, which is a missing domain; unknown cannot be wrong. A
+ consumer that knows where a share's storage is declares it with `with_domains`, and with none found what it declares
+ is the file's whole set.
+- **A virtual disk is decided by its bus type.** A disk whose `STORAGE_DEVICE_DESCRIPTOR` bus type is
+ `BusTypeFileBackedVirtual` is followed through the volume's storage dependency to the volume holding its backing file;
+ any other is not, and the dependency query is never asked. That query's page names no error for a volume that is not on
+ a virtual disk, so the code measured for one ([WV-D-10](../win-volume-sys/DESIGN-NOTES.md#wv-d-10)) is not built on.
+ The assistant's choice, made while implementing root `M39.2.2`. A file-backed disk whose host cannot be named leaves
+ the file unknown.
+- **A raw volume and a raw disk are followed the same way.** Added 2026-11-05, from the review of PR #182, which found
+ that a raw target on a virtual disk carried only its own disk while a file beside the disk's backing file carried the
+ host's, so a failed commit on the raw target did not reach the file's writes ([CONTRACT.md](CONTRACT.md)'s complete
+ declarations). `OpenedVolume::open` and `OpenedDisk::open` now derive at the open by the same walk, seeded from the
+ handle the open retains and never from its path again, which a drive letter reassigned since the open would resolve to
+ another volume (the second review round of the same PR): a volume's own disks are the extents its handle reported, and
+ that handle is asked for its storage dependency; a disk is itself, and its handle is asked with
+ `GET_STORAGE_DEPENDENCY_FLAG_DISK_HANDLE`. Only the host volumes found are opened by name, as a file's are. All or
+ nothing as for a file: a step that fails leaves the raw target unknown, where before it had its own disks, since its
+ own disks alone would be the partial set this decision never produces. A volume derives before it takes its lock,
+ which would refuse the derivation's own opens of the hosts. The virtual-disk routes are exercised only by the
+ administrator-only tests, which run on CI.
+- **What it costs.** Measured once, 2026-11-05, on the engineer's development machine: about half as much again as a local
+ open, and a small part of an open over a loopback share, where an unbuffered open already makes the one query a remote
+ file needs. Not a reason to defer anything; a figure for a consumer weighing the reserved opt-out below.
+- **An opt-out's shape is reserved, and none is built.** The engineer: "Reserve the shape but we don't need to implement it
+ today." If one is added, it is a method on the request (`OpenRequest` and `ByIdRequest`), as `with_security_descriptor`
+ is, and not a `FileOptions` field, since the options are the parameters of the OS calls ([DI-D-64](#di-d-64)); and it
+ takes a `#[non_exhaustive]` enum whose default derives, so a mode added later, skipping or a per-volume cache, breaks
+ no caller. **No work is scheduled for it.**
+- **What it amends.** `win-durable-ioring`'s file target says a file's domains "are what it cannot know", and
+ [API.md](API.md) section 13 calls the derivation "a locality helper's job outside this crate". Both change with the code,
+ in root `M39.2`.
+
+**Scheduled work:** root `M39.2` to `M39.4`, done ([COMPLETED-CHECKLIST.md](../../COMPLETED-CHECKLIST.md#m39)); the opt-out's shape is reserved and no work is scheduled for it.
+
+## DI-D-118: a directory's flush domains are predicted from its own handle, and a file's are checked against the prediction
+
+**Decided by the engineer, 2026-11-05 (root `M39.3`): "yes option 1".** The alternative was resolving the directory's
+name with `GetVolumePathNameW` and `GetVolumeNameForVolumeMountPointW`: two new entries, and a second statement of
+"which volume is this path on" beside the derivation, free to disagree with it.
+
+- **How.** `DirectoryDomains::predict` opens the directory through `windows-namespace-request-sys`' `OpenFile` with no
+ access and `FILE_FLAG_BACKUP_SEMANTICS`, which is what opening a directory needs, and runs [DI-D-117](#di-d-117)'s
+ derivation on that handle: one rule, the same bytes. The handle is a probe and never a target, so this is not the
+ flag [DI-D-64](#di-d-64) leaves out of a target's open options; that ruling stands.
+- **A prediction is never a declaration.** A file's domains are always what its own open derived. The directory's path is
+ resolved again at each create, so a junction swapped between the two sends the file elsewhere; `confirm` compares the
+ file's own domains with the prediction and reports a `DomainMismatch` with both, which is how that is noticed. An open
+ relative to the directory's handle would close the race, and stays parked as [DI-15.3](PARKED.md).
+- **A prediction that cannot be made is an error with its reason**, never an empty set that looks like an answer: a
+ directory on a share is not predicted, as a file on one has none found.
+- **It blocks**, as an open does; a caller that cannot block calls it on a thread that can.
+
+**Scheduled work:** none; root `M39.3` and `M39.4` are done ([COMPLETED-CHECKLIST.md](../../COMPLETED-CHECKLIST.md#m39)).
\ No newline at end of file
diff --git a/crates/win-durable-ioring/README.md b/crates/win-durable-ioring/README.md
index 2e363261e..62dd2407e 100644
--- a/crates/win-durable-ioring/README.md
+++ b/crates/win-durable-ioring/README.md
@@ -310,13 +310,22 @@ assert_eq!(scrubber.file_id()?, writer.file_id()?);
### A raw volume
+A file this crate opens has its flush domains found from its handle: the disks of its volume, all or nothing,
+and none for a file on a share ([DI-D-117](../durable-ioring/DESIGN-NOTES.md#di-d-117)). Before creating files,
+`DirectoryDomains::predict` gives the domains a file created in a directory will have, by the same derivation run on
+the directory's handle, and `confirm` checks a file's own against it, which is how a junction swapped between the two
+is noticed ([DI-D-118](../durable-ioring/DESIGN-NOTES.md#di-d-118)). A prediction is never a declaration.
+
A raw volume is a target too ([DI-D-61](../durable-ioring/DESIGN-NOTES.md#di-d-61)): one handle, one
commit per volume, and **regions are offsets the consumer allocates inside it**, not targets of their
own. `OpenedVolume` opens it by a path that can only name a volume, a drive letter or a volume GUID,
and finds what the backing must know for itself with documented device controls
([win-volume-sys](../win-volume-sys/README.md)): the disks it sits on, which become its flush
-domains (`disk_flush_domain` names a disk, so a consumer can declare the same domain for a file on
-it), and the sector it is addressed in, which is the alignment of its operations. A volume that is
+domains (`disk_flush_domain` names a disk, and a file this crate opens on it has the same domain found
+for it from its handle, [DI-D-117](../durable-ioring/DESIGN-NOTES.md#di-d-117)), with, for a virtual
+disk among them, the disks of the volume holding its backing file, as a file's are followed, so a raw
+volume on a VHD and a file beside the VHD's file share fate; and the sector it is
+addressed in, which is the alignment of its operations. A volume that is
remote, or not a volume, is refused. It claims `SharedDevice` when opened unbuffered and `BestEffort` when opened cached, and never
`Complete`: a lock keeps other handles off the volume, not other writers off its disks. Opening one for writing needs an
administrator, and `VolumeOptions::lock` takes the volume lock, which a write to a volume with a
@@ -339,7 +348,9 @@ let target = Win32FileTarget::from_volume(volume);
`OpenedDisk` opens a physical disk by its number, `\\.\PhysicalDriveN`, rather than a volume on it
([DI-D-62](../durable-ioring/DESIGN-NOTES.md#di-d-62)). The disk is its own flush domain, named by
-`disk_flush_domain` as a volume on it would name it, and its operations are aligned to its sector,
+`disk_flush_domain` as a volume on it would name it; a virtual disk carries as well the disks of the
+volume holding its backing file, found by asking the disk itself for its storage dependency
+([DI-D-117](../durable-ioring/DESIGN-NOTES.md#di-d-117)). Its operations are aligned to its sector,
which a device control reports. It claims `SharedDevice` when opened unbuffered and `BestEffort` when opened cached, never
`Complete`, for the reason a volume does not. Windows documents that it blocks a direct write to a sector a mounted volume covers, so a
disk with volumes on it is written through them, or has them locked or is taken offline first; the
diff --git a/crates/win-durable-ioring/sabotage.json b/crates/win-durable-ioring/sabotage.json
index e25cfe1ac..86e2688b1 100644
--- a/crates/win-durable-ioring/sabotage.json
+++ b/crates/win-durable-ioring/sabotage.json
@@ -1011,16 +1011,16 @@
"file": "src/windows_backend/file_target.rs",
"expect": "caught",
"why": "The class must come from how the file was opened, never from the target.",
- "find": [" domains: Vec::new(),\n warranty: B::WARRANTY,\n"],
- "replace": [" domains: Vec::new(),\n warranty: WarrantyClass::Complete,\n"]
+ "find": [" handle: file.handle().clone(),\n domains: found.clone(),\n found,\n declared: Vec::new(),\n warranty: B::WARRANTY,\n"],
+ "replace": [" handle: file.handle().clone(),\n domains: found.clone(),\n found,\n declared: Vec::new(),\n warranty: WarrantyClass::Complete,\n"]
},
{
"name": "DI-10.1: a target drops what its file offers",
"file": "src/windows_backend/file_target.rs",
"expect": "caught",
"why": "An unbuffered file's write-through capability is what lets a consumer ask for it; the kernel refuses it on a cached file.",
- "find": [" domains: Vec::new(),\n warranty: B::WARRANTY,\n capabilities: B::CAPABILITIES,\n"],
- "replace": [" domains: Vec::new(),\n warranty: B::WARRANTY,\n capabilities: Capabilities::NONE,\n"]
+ "find": [" handle: file.handle().clone(),\n domains: found.clone(),\n found,\n declared: Vec::new(),\n warranty: B::WARRANTY,\n capabilities: B::CAPABILITIES,\n"],
+ "replace": [" handle: file.handle().clone(),\n domains: found.clone(),\n found,\n declared: Vec::new(),\n warranty: B::WARRANTY,\n capabilities: Capabilities::NONE,\n"]
},
{
"name": "DI-10.1: a target takes no alignment from its file",
@@ -1131,7 +1131,7 @@
"file": "src/windows_backend/file_target.rs",
"expect": "caught",
"why": "The disk is the domain a commit failure on it can reach; a target that reports none shares fate with every target, which over-reports, and with no domain of its own, so a consumer cannot declare a file on the same disk against it.",
- "find": [" let found = vec![disk.flush_domain()];"],
+ "find": [" let found = disk.flush_domains().to_vec();"],
"replace": [" let found: Vec = Vec::new();"]
},
{
@@ -2301,6 +2301,186 @@
"why": "Popping rings for the commit a seal queued, and the backend will not take it until its completions are drained: DrainNeeded. That is nothing to pop yet and a reason to make room; treating it as a fault ends the run at the first seal under a pumped delivery.",
"find": [" | RefusalKind::DrainNeeded"],
"replace": [" | RefusalKind::Busy"]
- }
+ },
+ {
+ "name": "M39.2.2: a disk that cannot describe itself is taken as not file-backed",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "All or nothing: a step that fails leaves no domains, and taking a failure as \"not a virtual disk\" would leave out the backing file's volume, a partial set.",
+ "find": [" backed |= stack.is_file_backed(disk)?;"],
+ "replace": [" backed |= stack.is_file_backed(disk).unwrap_or(false);"]
+ },
+ {
+ "name": "M39.2.2: a virtual disk is not followed to its backing file",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "The disks of the volume holding a VHD's file are what a flush of the VHD reaches; leaving them out is a missing domain.",
+ "find": [" if backed {"],
+ "replace": [" if false && backed {"]
+ },
+ {
+ "name": "M39.2.2: the volume's own disks are not kept",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "A file's domains are its volume's disks; a VHD's own disk is kept as well, since an extra domain is safe.",
+ "find": [" disks.insert(disk);"],
+ "replace": [" let _ = disk;"]
+ },
+ {
+ "name": "M39.2.2: one volume short of the limit is refused",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "The limit is how many volumes are followed; one short of it is followed to the end.",
+ "find": [" if followed > MAX_VOLUMES {"],
+ "replace": [" if followed >= MAX_VOLUMES {"]
+ },
+ {
+ "name": "M39.2.2: a volume visited before is visited again",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "A loop in the dependencies must end with the domains found, not run to the limit and leave none.",
+ "find": [" if !visited.insert(volume.as_str().to_owned()) {"],
+ "replace": [" if !visited.insert(volume.as_str().to_owned()) && false {"]
+ },
+ {
+ "name": "M39.2.2: the final path is taken whole as the volume",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "A final path in GUID form is the volume and then the file's path; only the first part names the volume.",
+ "find": [" VolumePath::guid(&String::from_utf16(&path[..=end]).ok()?).ok()"],
+ "replace": [" VolumePath::guid(&String::from_utf16(path).ok()?).ok()"]
+ },
+ {
+ "name": "M39.2.2: the open finds no domains",
+ "file": "src/windows_backend/opened_file.rs",
+ "expect": "caught",
+ "why": "DI-D-117: a file this crate opens has its domains derived at the open.",
+ "find": [" let domains = file_domains::found(&file_domains::Windows, &handle);"],
+ "replace": [" let domains = Vec::new();"]
+ },
+ {
+ "name": "M39.2.2: a file target does not carry what its file found",
+ "file": "src/windows_backend/file_target.rs",
+ "expect": "caught",
+ "why": "What the open found is what the target reports, as a volume's are.",
+ "find": [" let found = file.flush_domains().to_vec();"],
+ "replace": [" let found: Vec = Vec::new();"]
+ },
+ {
+ "name": "CONTROL: M39.2.2, the limit on volumes followed is one larger",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "survives",
+ "why": "A control: the tests name the limit through the constant, so moving it changes nothing they claim. If this comes back caught, a test depends on the number rather than the rule.",
+ "find": ["const MAX_VOLUMES: usize = 16;"],
+ "replace": ["const MAX_VOLUMES: usize = 17;"]
+ },
+ {
+ "name": "M39.3: a file is confirmed whatever its domains",
+ "file": "src/windows_backend/directory_domains.rs",
+ "expect": "caught",
+ "why": "A prediction is checked against what the file's open found, which is how a consumer learns a file landed elsewhere.",
+ "find": [" if file.flush_domains() == self.domains.as_slice() {"],
+ "replace": [" if true {"]
+ },
+ {
+ "name": "M39.3: the directory is opened without backup semantics",
+ "file": "src/windows_backend/directory_domains.rs",
+ "expect": "caught",
+ "why": "Opening a directory needs FILE_FLAG_BACKUP_SEMANTICS; without it there is no handle to derive from.",
+ "find": [" flags: FILE_FLAG_BACKUP_SEMANTICS,"],
+ "replace": [" flags: 0,"]
+ },
+ {
+ "name": "M39.3: a prediction that could not be made is an empty one",
+ "file": "src/windows_backend/directory_domains.rs",
+ "expect": "caught",
+ "why": "A prediction that failed is reported with its reason, and not handed back as a set that looks like an answer.",
+ "find": [" let domains = file_domains::derive(stack, handle)?;"],
+ "replace": [" let domains = file_domains::derive(stack, handle).unwrap_or_default();"]
+ },
+ {
+ "name": "M39.3: a mismatch reports the prediction as what was found",
+ "file": "src/windows_backend/directory_domains.rs",
+ "expect": "caught",
+ "why": "A mismatch carries both sides, so a consumer can see where the file landed.",
+ "find": [" found: file.flush_domains().to_vec(),"],
+ "replace": [" found: self.domains.clone(),"]
+ },
+ {
+ "name": "PR #182: a raw disk that is a virtual disk is not followed to its backing file",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "A raw virtual disk's flushes reach the disks holding its backing file, as a file's on it do; a raw target left without them does not share fate with a file on the host.",
+ "find": [
+ " if backed {",
+ " let hosts = hosts()?;"
+ ],
+ "replace": [
+ " if false && backed {",
+ " let hosts = hosts()?;"
+ ]
+ },
+ {
+ "name": "PR #182: a raw disk's own disk is not kept",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "A raw disk is its own flush domain first; following its host adds to that and removes nothing.",
+ "find": [
+ " let mut disks: Vec = own.to_vec();"
+ ],
+ "replace": [
+ " let mut disks: Vec = Vec::new();"
+ ]
+ },
+ {
+ "name": "PR #182: a raw target on plain disks still asks its handle for hosts",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "The dependency query's answer for a target on no virtual disk is undocumented, so it is asked only when a disk is backed by a file.",
+ "find": [
+ " if backed {",
+ " let hosts = hosts()?;"
+ ],
+ "replace": [
+ " if true || backed {",
+ " let hosts = hosts()?;"
+ ]
+ },
+ {
+ "name": "PR #182: a raw volume's own disks are not taken from its handle's extents",
+ "file": "src/windows_backend/opened_volume.rs",
+ "expect": "caught",
+ "why": "A raw volume's domains are derived from the handle it holds, not from its path again; the extents that handle reported are its own disks.",
+ "find": [
+ " let own: Vec = extents.iter().map(|extent| extent.disk_number).collect();"
+ ],
+ "replace": [
+ " let own: Vec = Vec::new();"
+ ]
+ },
+ {
+ "name": "PR #182: the whole final path is decoded, not only its volume prefix",
+ "file": "src/windows_backend/file_domains.rs",
+ "expect": "caught",
+ "why": "A Windows name may hold a lone surrogate, which is not Unicode; decoding the whole path refuses such a file's volume and leaves it unknown for no reason.",
+ "find": [
+ " VolumePath::guid(&String::from_utf16(&path[..=end]).ok()?).ok()"
+ ],
+ "replace": [
+ " VolumePath::guid(&String::from_utf16(path).ok()?[..=end]).ok()"
+ ]
+ },
+ {
+ "name": "PR #182: a domain mismatch reports only how many domains each side had",
+ "file": "src/windows_backend/directory_domains.rs",
+ "expect": "caught",
+ "why": "Equal counts are the common mismatch, a file on a different disk; the message must carry the domains themselves to be diagnosed.",
+ "find": [
+ " self.predicted, self.found"
+ ],
+ "replace": [
+ " self.predicted.len(), self.found.len()"
+ ]
+ }
]
}
diff --git a/crates/win-durable-ioring/src/dioring/tests.rs b/crates/win-durable-ioring/src/dioring/tests.rs
index b3fc29662..73acdac4b 100644
--- a/crates/win-durable-ioring/src/dioring/tests.rs
+++ b/crates/win-durable-ioring/src/dioring/tests.rs
@@ -58,6 +58,7 @@ mod keys;
mod delays;
// The default diagnostic sink, and a chosen one in its place: DI-12.1.6.
mod diagnostics;
+mod working_model;
type Ring = Dioring>;
type V = crate::WindowsIds;
@@ -306,9 +307,12 @@ fn file(key: u64, temp: &TempFile, domains: &[&str]) -> TargetSetup {
TargetSetup::new(IoTargetKey(key), target(temp.open(), domains))
}
-/// A target over `file`, declared with `domains`.
+/// A target over `file` with exactly `domains`: the disk found for the temporary file is set aside,
+/// since these tests use files on one disk to stand for files on separate devices (DI-D-117).
pub(crate) fn target(file: OpenedFile, domains: &[&str]) -> Win32FileTarget {
- Win32FileTarget::new(file).with_domains(domains.iter().map(|d| domain(d)))
+ Win32FileTarget::new(file)
+ .without_found_domains()
+ .with_domains(domains.iter().map(|d| domain(d)))
}
/// A provider that serves fixed domains and is never asked for anything here.
diff --git a/crates/win-durable-ioring/src/dioring/tests/raw_disk.rs b/crates/win-durable-ioring/src/dioring/tests/raw_disk.rs
index 7ce5562e2..576792880 100644
--- a/crates/win-durable-ioring/src/dioring/tests/raw_disk.rs
+++ b/crates/win-durable-ioring/src/dioring/tests/raw_disk.rs
@@ -23,7 +23,7 @@ use io_buffers::AlignedBuf;
use super::QUEUE;
use crate::dioring::{DeliveryMode, Dioring, Setup, TargetSetup};
-use crate::scratch_volume::ScratchDisk;
+use crate::scratch_volume::{ScratchDisk, host_flush_domains};
use crate::windows_backend::{
DiskOptions, DiskPath, OpenedDisk, ReadOnly, ShareMode, SharingViolation, Unbuffered,
Win32FileTarget, disk_flush_domain,
@@ -161,10 +161,20 @@ fn a_raw_disks_sector_is_found_and_its_target_names_the_disk() {
let target = Win32FileTarget::from_disk(disk);
assert_eq!(target.warranty(), WarrantyClass::SharedDevice);
- assert_eq!(
- target.flush_domains(),
- [disk_flush_domain(scratch.disk_number())]
+ // Itself, and, since it is virtual, the disks of the volume holding its file, asked of the disk.
+ let host = host_flush_domains();
+ let reported = target.flush_domains();
+ assert!(
+ reported.contains(&disk_flush_domain(scratch.disk_number())),
+ "{reported:?}"
);
+ for domain in &host {
+ assert!(
+ reported.contains(domain),
+ "the host's {domain:?} in {reported:?}"
+ );
+ }
+ assert_eq!(reported.len(), host.len() + 1, "each once: {reported:?}");
assert!(target.capabilities().write_through());
}
diff --git a/crates/win-durable-ioring/src/dioring/tests/raw_volume.rs b/crates/win-durable-ioring/src/dioring/tests/raw_volume.rs
index 029e9467d..6ec8dc9ed 100644
--- a/crates/win-durable-ioring/src/dioring/tests/raw_volume.rs
+++ b/crates/win-durable-ioring/src/dioring/tests/raw_volume.rs
@@ -22,7 +22,7 @@ use io_buffers::AlignedBuf;
use super::QUEUE;
use crate::dioring::{DeliveryMode, Dioring, Setup, TargetSetup};
-use crate::scratch_volume::ScratchVolume;
+use crate::scratch_volume::{ScratchVolume, host_flush_domains};
use crate::windows_backend::{
OpenedVolume, ReadOnly, ShareMode, SharingViolation, Unbuffered, VolumeOptions, VolumePath,
Win32FileTarget, disk_flush_domain,
@@ -166,7 +166,17 @@ fn a_raw_volumes_disks_and_sector_are_found_and_its_target_reports_them() {
let target = Win32FileTarget::from_volume(volume);
assert_eq!(target.warranty(), WarrantyClass::SharedDevice);
- assert_eq!(target.flush_domains(), [disk_flush_domain(disk)]);
+ // Its own disk, and, since that disk is virtual, the disks of the volume holding its file.
+ let host = host_flush_domains();
+ let reported = target.flush_domains();
+ assert!(reported.contains(&disk_flush_domain(disk)), "{reported:?}");
+ for domain in &host {
+ assert!(
+ reported.contains(domain),
+ "the host's {domain:?} in {reported:?}"
+ );
+ }
+ assert_eq!(reported.len(), host.len() + 1, "each once: {reported:?}");
assert!(target.capabilities().write_through());
}
diff --git a/crates/win-durable-ioring/src/dioring/tests/working_model.rs b/crates/win-durable-ioring/src/dioring/tests/working_model.rs
new file mode 100644
index 000000000..14f70b4ad
--- /dev/null
+++ b/crates/win-durable-ioring/src/dioring/tests/working_model.rs
@@ -0,0 +1,321 @@
+// Copyright (c) 2026 Mike Grier
+//! The working model end to end (root `M39.4`): a database's data directory and its log directory,
+//! each file opened through this crate so its flush domains are derived ([DI-D-117]), the
+//! directories predicted and the files confirmed ([DI-D-118]), and a failure of the data file's
+//! storage shown to reach the log's writes exactly when the two share a disk.
+//!
+//! **Which case runs depends on the machine.** The data directory is under the temporary directory;
+//! the log directory is under `RUNNER_TEMP` when it is set, which on a CI runner is on a work disk of
+//! its own, and under the temporary directory otherwise. On one disk the shared case runs; on two,
+//! the separate case. Each run says which, and nothing guarantees the separate case runs anywhere.
+//!
+//! **Why an imported failure and not a failed flush.** A seal flushes both files, and a flush that
+//! fails on the data file may complete before or after the log's succeeds, which decides whether the
+//! log's write was already covered. An import scoped to the data file's domains is the same question
+//! -- the data disk's failure -- with no order in it.
+//!
+//! [DI-D-117]: ../../../../durable-ioring/DESIGN-NOTES.md#di-d-117
+//! [DI-D-118]: ../../../../durable-ioring/DESIGN-NOTES.md#di-d-118
+
+use std::fs::{self, File};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use std::time::{Duration, Instant};
+
+use durable_ioring::oracle::ObservedFailures;
+use durable_ioring::{
+ DurableRing, Entry, EntryOf, FlushDomain, ImportScope, IoTarget, IoTargetKey, OpId, Outcome,
+};
+use io_buffers::AlignedBuf;
+
+use super::pushes::{BOUND, Harness, instance};
+use super::{CAPACITY, QUEUE, V};
+use crate::dioring::tests::wtf;
+use crate::dioring::{DeliveryMode, Dioring, Setup, TargetSetup};
+use crate::scratch_volume::{ScratchDisk, ScratchVolume, scratch_directory};
+use crate::windows_backend::{
+ Cached, DirectoryDomains, DiskOptions, DiskPath, FileOptions, OpenedDisk, OpenedFile,
+ OpenedVolume, Unbuffered, VolumeOptions, Win32FileTarget,
+};
+
+const LOG: IoTargetKey = IoTargetKey(10);
+const DATA: IoTargetKey = IoTargetKey(11);
+
+/// A directory of its own under `parent`, removed when dropped.
+struct Directory(PathBuf);
+
+impl Directory {
+ fn under(parent: &Path, tag: &str) -> Self {
+ let path = parent.join(format!(
+ "dioring-model-{tag}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("after the epoch")
+ .as_nanos()
+ ));
+ fs::create_dir_all(&path).expect("create the directory");
+ Self(path)
+ }
+
+ /// A file named `name` in the directory, created, then opened through this crate.
+ fn open(&self, name: &str) -> (OpenedFile, DirectoryDomains) {
+ let predicted = DirectoryDomains::predict(wtf(&self.0)).expect("predict the directory");
+ let path = self.0.join(name);
+ File::create(&path).expect("create the file");
+ let file = OpenedFile::::open(wtf(&path), FileOptions::new().read().write())
+ .expect("open the file");
+ predicted
+ .confirm(&file)
+ .expect("the file is where its directory is");
+ (file, predicted)
+ }
+}
+
+impl Drop for Directory {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+}
+
+/// The ops a failure scoped to `domains` suspects, by sequence number, after one write to the log
+/// (sequence 0) and one to the data file (sequence 1).
+fn suspects_of_a_failure_of(harness: &mut Harness, domains: &[FlushDomain]) -> Vec {
+ let imported = harness
+ .ring
+ .import_failure(ImportScope::Domains(Arc::from(domains)), |_| {})
+ .expect("import");
+ let failed = match harness.next_entry() {
+ Entry::Failed(failed) => failed,
+ other => panic!("expected Failed, got {other:?}"),
+ };
+ assert_eq!(failed.id, imported.failure);
+ harness
+ .suspects(failed.id)
+ .iter()
+ .map(|op| op.seq)
+ .collect()
+}
+
+/// A ring over `log` and `data`, each with the domains its open derived, and one write to each.
+fn ring_with_a_write_to_each(log: OpenedFile, data: OpenedFile) -> Harness {
+ ring_over(Win32FileTarget::new(log), Win32FileTarget::new(data))
+}
+
+/// A ring over `log` and `data` with one write to each, both completed.
+fn ring_over(log: Win32FileTarget, data: Win32FileTarget) -> Harness {
+ let mut harness = Harness::new(instance(
+ vec![TargetSetup::new(LOG, log), TargetSetup::new(DATA, data)],
+ Vec::new(),
+ ));
+ harness.write(LOG, 0, b"log record", 1, 1);
+ harness.write(DATA, 0, b"data page", 1, 2);
+ harness.next_n(2);
+ harness
+}
+
+fn shared(a: &[FlushDomain], b: &[FlushDomain]) -> bool {
+ a.iter().any(|domain| b.contains(domain))
+}
+
+#[test]
+fn a_failure_of_the_data_files_storage_reaches_the_log_exactly_when_they_share_a_disk() {
+ let log_root = std::env::var_os("RUNNER_TEMP").map_or_else(std::env::temp_dir, PathBuf::from);
+ let data_directory = Directory::under(&std::env::temp_dir(), "data");
+ let log_directory = Directory::under(&log_root, "log");
+
+ let (data, _) = data_directory.open("data.db");
+ let (log, _) = log_directory.open("log.ldf");
+ let data_domains = data.flush_domains().to_vec();
+ let log_domains = log.flush_domains().to_vec();
+ assert!(
+ !data_domains.is_empty() && !log_domains.is_empty(),
+ "both derived"
+ );
+ let together = shared(&data_domains, &log_domains);
+ eprintln!(
+ "the {} case: data on {data_domains:?}, log on {log_domains:?}",
+ if together { "shared" } else { "separate" }
+ );
+
+ let mut harness = ring_with_a_write_to_each(log, data);
+ let suspects = suspects_of_a_failure_of(&mut harness, &data_domains);
+
+ let expected: &[u64] = if together { &[0, 1] } else { &[1] };
+ assert_eq!(
+ suspects, expected,
+ "the data file's write is suspect, and the log's exactly when the files share a disk"
+ );
+ harness.finish();
+}
+
+#[test]
+#[ignore = "needs an administrator: creates and attaches a virtual disk"]
+fn a_file_on_a_virtual_disk_shares_fate_with_the_volume_holding_its_backing_file() {
+ // The scratch disk's file is in the temporary directory, so a file on it and a file in the
+ // temporary directory share the host's disks, which only the virtual-disk hop finds.
+ let scratch = ScratchVolume::create_formatted(64).expect("a formatted scratch volume");
+ let on_disk = Directory::under(&scratch.root(), "vhd");
+ let on_host = Directory::under(&std::env::temp_dir(), "host");
+
+ let (vhd_file, _) = on_disk.open("log.ldf");
+ let (host_file, _) = on_host.open("data.db");
+ let vhd_domains = vhd_file.flush_domains().to_vec();
+ let host_domains = host_file.flush_domains().to_vec();
+ for domain in &host_domains {
+ assert!(
+ vhd_domains.contains(domain),
+ "the host's {domain:?} is among the virtual disk's: {vhd_domains:?}"
+ );
+ }
+ let own: Vec = vhd_domains
+ .iter()
+ .filter(|domain| !host_domains.contains(domain))
+ .cloned()
+ .collect();
+ assert!(!own.is_empty(), "and the virtual disk's own disk besides");
+
+ let mut harness = ring_with_a_write_to_each(vhd_file, host_file);
+ assert_eq!(
+ suspects_of_a_failure_of(&mut harness, &host_domains),
+ [0, 1],
+ "a failure of the host's disks reaches the file on the virtual disk"
+ );
+ assert_eq!(
+ suspects_of_a_failure_of(&mut harness, &own),
+ [0],
+ "a failure of the virtual disk alone does not reach the host's file"
+ );
+ harness.finish();
+ drop(on_disk);
+}
+
+/// An instance over raw targets, whose buffers are aligned: an unbuffered disk or volume takes
+/// only sector-aligned addresses and lengths, which the harness's `Vec` does not promise.
+type RawRing = Dioring;
+
+/// The next entry of `ring`, within the bound.
+fn next_raw(ring: &mut RawRing) -> EntryOf {
+ let started = Instant::now();
+ loop {
+ if let Some(entry) = ring.pop().expect("pop") {
+ return entry;
+ }
+ assert!(started.elapsed() < BOUND, "nothing arrived");
+ std::thread::sleep(Duration::from_millis(1));
+ }
+}
+
+/// The writes, by the order they were pushed in `ops`, that a failure scoped to `domains` suspects.
+fn raw_suspects(
+ ring: &mut RawRing,
+ told: &ObservedFailures,
+ ops: &[OpId],
+ domains: &[FlushDomain],
+) -> Vec {
+ let imported = ring
+ .import_failure(ImportScope::Domains(Arc::from(domains)), |_| {})
+ .expect("import");
+ let failed = match next_raw(ring) {
+ Entry::Failed(failed) => failed,
+ other => panic!("expected Failed, got {other:?}"),
+ };
+ assert_eq!(failed.id, imported.failure);
+ let suspect: Vec = told
+ .suspects(failed.id)
+ .expect("the observer was told of the failure")
+ .iter()
+ .map(|write| write.op)
+ .collect();
+ ops.iter()
+ .enumerate()
+ .filter(|(_, op)| suspect.contains(op))
+ .map(|(index, _)| index)
+ .collect()
+}
+
+/// A raw `target` on a scratch disk and a file beside the disk's backing file: a failure of the
+/// host's storage reaches the raw target's write, and one of the raw target's own disk alone does not
+/// reach the file's. Each gets one sector, aligned, at an offset of three sectors.
+fn a_raw_target_on_a_virtual_disk_shares_fate_with_a_file_on_its_host(target: Win32FileTarget) {
+ let on_host = Directory::under(&scratch_directory(), "raw-host");
+ let (host_file, _) = on_host.open("data.db");
+ let host_domains = host_file.flush_domains().to_vec();
+ let own: Vec = target
+ .flush_domains()
+ .iter()
+ .filter(|domain| !host_domains.contains(domain))
+ .cloned()
+ .collect();
+ assert!(!own.is_empty(), "the virtual disk's own disk");
+ let sector = target.geometry().offset_alignment() as usize;
+
+ let told = Arc::new(ObservedFailures::new());
+ let (mut ring, lineage) = RawRing::new(Setup {
+ submission_queue_size: QUEUE,
+ completion_queue_size: QUEUE,
+ files: vec![
+ TargetSetup::new(LOG, target),
+ TargetSetup::new(DATA, Win32FileTarget::new(host_file)),
+ ],
+ buffers: Vec::>::new(),
+ provider: None,
+ capacity: CAPACITY,
+ diagnostics: None,
+ failure_observer: Some(told.clone()),
+ delivery: DeliveryMode::Pool(None),
+ })
+ .expect("an instance over the raw target and the file");
+ let sector_of =
+ |fill: u8| AlignedBuf::from_bytes(&vec![fill; sector], sector).expect("aligned");
+ let at = 3 * sector as u64;
+ let ops = [
+ ring.write(LOG, at, sector_of(0xA1), lineage.at(1), 1)
+ .expect("push to the raw target"),
+ ring.write(DATA, 0, sector_of(0xD1), lineage.at(1), 2)
+ .expect("push to the file"),
+ ];
+ for _ in 0..ops.len() {
+ match next_raw(&mut ring) {
+ Entry::Op(completion) => assert!(
+ matches!(completion.outcome, Outcome::Transferred(_)),
+ "{completion:?}"
+ ),
+ other => panic!("expected a completion, got {other:?}"),
+ }
+ }
+
+ assert_eq!(
+ raw_suspects(&mut ring, &told, &ops, &host_domains),
+ [0, 1],
+ "a failure of the host's disks reaches the raw target on the virtual disk"
+ );
+ assert_eq!(
+ raw_suspects(&mut ring, &told, &ops, &own),
+ [0],
+ "a failure of the virtual disk alone does not reach the host's file"
+ );
+}
+#[test]
+#[ignore = "needs an administrator: creates and attaches a virtual disk"]
+fn a_raw_volume_on_a_virtual_disk_shares_fate_with_a_file_on_its_host() {
+ let scratch = ScratchVolume::create(64).expect("a scratch volume");
+ let volume = OpenedVolume::::open(&scratch.path(), VolumeOptions::new())
+ .expect("open the raw volume");
+ a_raw_target_on_a_virtual_disk_shares_fate_with_a_file_on_its_host(
+ Win32FileTarget::from_volume(volume),
+ );
+}
+
+#[test]
+#[ignore = "needs an administrator: creates and attaches a virtual disk"]
+fn a_raw_disk_that_is_virtual_shares_fate_with_a_file_on_its_host() {
+ let scratch = ScratchDisk::create(64).expect("a scratch disk");
+ let disk =
+ OpenedDisk::::open(DiskPath::number(scratch.disk_number()), DiskOptions::new())
+ .expect("open the raw disk");
+ a_raw_target_on_a_virtual_disk_shares_fate_with_a_file_on_its_host(Win32FileTarget::from_disk(
+ disk,
+ ));
+}
diff --git a/crates/win-durable-ioring/src/scratch_volume.rs b/crates/win-durable-ioring/src/scratch_volume.rs
index 0ad55d407..2e253379b 100644
--- a/crates/win-durable-ioring/src/scratch_volume.rs
+++ b/crates/win-durable-ioring/src/scratch_volume.rs
@@ -17,7 +17,10 @@ use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
-use crate::windows_backend::VolumePath;
+use durable_ioring::FlushDomain;
+use wtf_string::Wtf16String;
+
+use crate::windows_backend::{Cached, FileOptions, OpenedFile, VolumePath};
/// How long a new volume has to appear at its drive letter before the setup is given up on.
const MOUNT_PATIENCE: Duration = Duration::from_secs(30);
@@ -51,6 +54,11 @@ pub(crate) fn create_script(vhd: &Path, megabytes: u32, letter: char) -> String
)
}
+/// The `diskpart` script that puts an NTFS file system on the volume at drive letter `letter`.
+pub(crate) fn format_script(letter: char) -> String {
+ format!("select volume {letter}\nformat fs=ntfs quick\n")
+}
+
/// The `diskpart` script that detaches the virtual disk at `vhd`.
pub(crate) fn detach_script(vhd: &Path) -> String {
format!(
@@ -134,6 +142,28 @@ pub(crate) fn scratch_directory() -> PathBuf {
}
}
+/// The flush domains of a file beside the scratch disks' backing files: those a raw target on a
+/// scratch disk must carry besides its own disk, since a flush of it reaches the host's storage.
+pub(crate) fn host_flush_domains() -> Vec {
+ // Numbered as well, so tests running at once in this process each have their own.
+ let path = scratch_directory().join(format!(
+ "durable-ioring-host-{}-{}.tmp",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ));
+ std::fs::File::create(&path).expect("create a file beside the scratch disks");
+ let opened = OpenedFile::::open(
+ Wtf16String::from_os_str(path.as_os_str()),
+ FileOptions::new().read().write(),
+ )
+ .expect("open it");
+ let domains = opened.flush_domains().to_vec();
+ drop(opened);
+ let _ = std::fs::remove_file(&path);
+ assert!(!domains.is_empty(), "the host's domains are found");
+ domains
+}
+
/// A virtual disk attached with one raw partition, and the volume path that names it.
pub(crate) struct ScratchVolume {
vhd: PathBuf,
@@ -163,6 +193,30 @@ impl ScratchVolume {
Ok(volume)
}
+ /// A volume of `megabytes` with an NTFS file system on it, for a test that needs files on a
+ /// virtual disk rather than the raw volume itself.
+ pub(crate) fn create_formatted(megabytes: u32) -> io::Result {
+ let volume = Self::create(megabytes)?;
+ run_diskpart(&format_script(volume.letter), "format")?;
+ let started = Instant::now();
+ while !volume.root().is_dir() {
+ if started.elapsed() >= MOUNT_PATIENCE {
+ return Err(io::Error::other(format!(
+ "the scratch file system did not appear at {}",
+ volume.root().display()
+ )));
+ }
+ std::thread::sleep(Duration::from_millis(250));
+ }
+ Ok(volume)
+ }
+
+ /// The root directory of the volume's file system, for a volume made by
+ /// [`create_formatted`](Self::create_formatted).
+ pub(crate) fn root(&self) -> PathBuf {
+ PathBuf::from(format!("{}:\\", self.letter))
+ }
+
/// The path to open the volume at.
pub(crate) fn path(&self) -> VolumePath {
VolumePath::drive(self.letter).expect("a drive letter")
diff --git a/crates/win-durable-ioring/src/scratch_volume/tests.rs b/crates/win-durable-ioring/src/scratch_volume/tests.rs
index f8625d341..56273712c 100644
--- a/crates/win-durable-ioring/src/scratch_volume/tests.rs
+++ b/crates/win-durable-ioring/src/scratch_volume/tests.rs
@@ -3,7 +3,7 @@
use std::path::Path;
use super::{
- associated_disk, create_disk_script, create_script, detach_script, free_letter,
+ associated_disk, create_disk_script, create_script, detach_script, format_script, free_letter,
letter_is_taken, online_script, release_letter, reserve_letter, scratch_directory,
};
@@ -22,6 +22,14 @@ fn the_create_script_makes_attaches_partitions_and_assigns() {
assert_eq!(lines[4], "assign letter=Y");
}
+#[test]
+fn the_format_script_selects_the_volume_by_its_letter_and_puts_ntfs_on_it() {
+ assert_eq!(
+ format_script('Y').lines().collect::>(),
+ ["select volume Y", "format fs=ntfs quick"]
+ );
+}
+
#[test]
fn the_detach_script_selects_the_disk_it_detaches() {
let script = detach_script(Path::new(r"C:\Temp\s.vhdx"));
diff --git a/crates/win-durable-ioring/src/windows_backend.rs b/crates/win-durable-ioring/src/windows_backend.rs
index f2ce19620..1870d1e4c 100644
--- a/crates/win-durable-ioring/src/windows_backend.rs
+++ b/crates/win-durable-ioring/src/windows_backend.rs
@@ -46,8 +46,10 @@ use durable_ioring::backend::{
};
use durable_ioring::{IoFault, IoTargetKey, WriteCaching};
+mod directory_domains;
mod disposition;
mod file_attributes;
+mod file_domains;
mod file_id;
mod file_rights;
mod file_target;
@@ -63,6 +65,7 @@ mod wait;
pub use wait::CompletionEvent;
// The markers are the core's (DI-D-66); this module states what each asks of Win32.
+pub use directory_domains::{DirectoryDomains, DomainMismatch};
pub use disposition::Disposition;
pub use durable_ioring::{
CanRead, CanWrite, DataAccess, HasData, NoData, ReadOnly, ReadWrite, WriteOnly,
diff --git a/crates/win-durable-ioring/src/windows_backend/directory_domains.rs b/crates/win-durable-ioring/src/windows_backend/directory_domains.rs
new file mode 100644
index 000000000..3654fd01a
--- /dev/null
+++ b/crates/win-durable-ioring/src/windows_backend/directory_domains.rs
@@ -0,0 +1,129 @@
+// Copyright (c) 2026 Mike Grier
+//! The flush domains a file created in a directory will have, predicted before it is created, and a
+//! file's own checked against the prediction (DI-D-118).
+//!
+//! A consumer presents storage roots as directories and creates files in them by name, and may want
+//! to know where those files will land before creating them -- a log apart from data, say. The
+//! prediction opens the directory through `windows-namespace-request-sys`' `OpenFile`, with no
+//! access and `FILE_FLAG_BACKUP_SEMANTICS`, which is what opening a directory needs, and runs the
+//! same derivation a file's open runs ([`file_domains`], DI-D-117) on that
+//! handle, so the prediction and the file's domains are one rule and the same bytes.
+//!
+//! **A prediction is never a declaration.** A file's domains are always what its own open derived.
+//! The directory's path is resolved again at each create, so a junction swapped between the two
+//! sends the file elsewhere; [`DirectoryDomains::confirm`] is how a consumer learns that happened.
+
+use std::fmt;
+use std::io;
+
+use durable_ioring::{DataAccess, FlushDomain};
+use win_shared_os_owned_handle::SharedHandle;
+use windows_sys::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, OPEN_EXISTING};
+use wtf_string::Wtf16String;
+
+use super::file_domains::{self, StorageStack};
+use super::opened_file::open_request::{self, Parameters};
+use super::opened_file::{Buffering, OpenedFile};
+use super::share_mode::ShareMode;
+
+#[cfg(test)]
+mod tests;
+
+/// What the prediction asks of `CreateFileW` for the directory: no access, which is enough to read
+/// its final path, shared with everyone, and the flag a directory is opened with.
+const PROBE: Parameters = Parameters {
+ access: 0,
+ disposition: OPEN_EXISTING,
+ flags: FILE_FLAG_BACKUP_SEMANTICS,
+ share: ShareMode::ALL.bits(),
+};
+
+/// The flush domains predicted for files created in a directory.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct DirectoryDomains {
+ directory: Wtf16String,
+ domains: Vec,
+}
+
+impl DirectoryDomains {
+ /// Predict the flush domains a file created in `directory` will have: the disks of the
+ /// directory's volume, mount points included, found as a file's are (DI-D-117).
+ ///
+ /// **This blocks for as long as opening the directory does**, which on a network path can be a
+ /// long time; a caller that cannot block calls it on a thread that can.
+ ///
+ /// # Errors
+ ///
+ /// The open's own error, such as `ERROR_FILE_NOT_FOUND`; and, where a file's open would find no
+ /// domains -- a directory on a share, or a volume that could not be read -- the reason, since a
+ /// prediction that could not be made is worth saying and not worth guessing.
+ pub fn predict(directory: Wtf16String) -> io::Result {
+ let request = open_request::request(directory.clone(), PROBE)?;
+ let handle = SharedHandle::from(open_request::perform(&request)?);
+ Self::from_handle(&file_domains::Windows, directory, &handle)
+ }
+
+ /// The prediction for the directory open as `handle`, from the storage `stack`.
+ pub(super) fn from_handle(
+ stack: &impl StorageStack,
+ directory: Wtf16String,
+ handle: &SharedHandle,
+ ) -> io::Result {
+ let domains = file_domains::derive(stack, handle)?;
+ Ok(Self { directory, domains })
+ }
+
+ /// The directory, as it was given.
+ #[must_use]
+ pub fn directory(&self) -> &Wtf16String {
+ &self.directory
+ }
+
+ /// The domains predicted, in the order a file's are found.
+ #[must_use]
+ pub fn domains(&self) -> &[FlushDomain] {
+ &self.domains
+ }
+
+ /// Check `file`'s own domains, found when it was opened, against the prediction.
+ ///
+ /// # Errors
+ ///
+ /// [`DomainMismatch`] when they differ: the file landed somewhere the directory was not when it
+ /// was predicted -- a junction swapped, a volume remounted -- or its open found none, as a file on
+ /// a share does. The file's domains are what it declares either way.
+ pub fn confirm(
+ &self,
+ file: &OpenedFile,
+ ) -> Result<(), DomainMismatch> {
+ if file.flush_domains() == self.domains.as_slice() {
+ Ok(())
+ } else {
+ Err(DomainMismatch {
+ predicted: self.domains.clone(),
+ found: file.flush_domains().to_vec(),
+ })
+ }
+ }
+}
+
+/// A file's flush domains were not the ones predicted for its directory.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct DomainMismatch {
+ /// What the directory predicted.
+ pub predicted: Vec,
+ /// What the file's open found; empty when it found none.
+ pub found: Vec,
+}
+
+impl fmt::Display for DomainMismatch {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "the file's flush domains are not the ones predicted for its directory: predicted {:?}, found {:?}",
+ self.predicted, self.found
+ )
+ }
+}
+
+impl std::error::Error for DomainMismatch {}
diff --git a/crates/win-durable-ioring/src/windows_backend/directory_domains/tests.rs b/crates/win-durable-ioring/src/windows_backend/directory_domains/tests.rs
new file mode 100644
index 000000000..ec1db5c78
--- /dev/null
+++ b/crates/win-durable-ioring/src/windows_backend/directory_domains/tests.rs
@@ -0,0 +1,214 @@
+// Copyright (c) 2026 Mike Grier
+//! Tests for predicting a directory's flush domains and confirming a file's against it.
+
+use std::ffi::OsString;
+use std::fs::{self, File};
+use std::io;
+use std::os::windows::ffi::OsStringExt;
+use std::path::{Path, PathBuf};
+
+use durable_ioring::{FlushDomain, NoData};
+use win_shared_os_owned_handle::SharedHandle;
+
+use super::DirectoryDomains;
+use crate::dioring::tests::{TempFile, wtf};
+use crate::windows_backend::file_domains::StorageStack;
+use crate::windows_backend::{
+ Cached, FileOptions, OpenedFile, OpenedVolume, VolumeOptions, VolumePath, disk_flush_domain,
+};
+
+/// A directory of its own under the temporary directory, removed when dropped.
+struct TempDirectory(PathBuf);
+
+impl TempDirectory {
+ fn new(tag: &str) -> Self {
+ let path = std::env::temp_dir().join(format!(
+ "durable-ioring-dir-{tag}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("after the epoch")
+ .as_nanos()
+ ));
+ fs::create_dir_all(&path).expect("create the directory");
+ Self(path)
+ }
+}
+
+impl Drop for TempDirectory {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+}
+
+fn drive_of(path: &Path) -> VolumePath {
+ let text = path.to_str().expect("a UTF-8 temp path");
+ let plain = text.strip_prefix(r"\\?\").unwrap_or(text);
+ VolumePath::drive(plain.chars().next().expect("a drive")).expect("a drive letter")
+}
+
+fn expected_for(path: &Path) -> Vec {
+ OpenedVolume::::open(&drive_of(path), VolumeOptions::new())
+ .expect("open the volume with no data access")
+ .flush_domains()
+}
+
+/// A storage stack whose every step fails.
+struct Broken;
+
+impl StorageStack for Broken {
+ fn volume_of(&self, _file: &SharedHandle) -> io::Result {
+ Err(io::Error::other("no volume"))
+ }
+ fn disks_of(&self, _volume: &VolumePath) -> io::Result> {
+ Err(io::Error::other("no extents"))
+ }
+ fn is_file_backed(&self, _disk: u32) -> io::Result {
+ Err(io::Error::other("no descriptor"))
+ }
+ fn hosts_of(&self, _volume: &VolumePath) -> io::Result> {
+ Err(io::Error::other("no dependency"))
+ }
+}
+
+#[test]
+fn a_directory_predicts_the_disks_of_its_volume() {
+ let directory = TempDirectory::new("predict");
+
+ let predicted = DirectoryDomains::predict(wtf(&directory.0)).expect("a local directory");
+
+ assert!(!predicted.domains().is_empty());
+ assert_eq!(predicted.domains(), expected_for(&directory.0).as_slice());
+ assert_eq!(predicted.directory(), &wtf(&directory.0));
+}
+
+#[test]
+fn a_directory_whose_name_is_not_unicode_still_predicts_its_volumes_disks() {
+ // A lone surrogate is a valid Windows name and not Unicode; only the volume prefix of the final
+ // path is read, so it changes nothing.
+ let parent = TempDirectory::new("surrogate");
+ let path = parent
+ .0
+ .join(OsString::from_wide(&[0xD800, u16::from(b'd')]));
+ fs::create_dir(&path).expect("create a directory named with a lone surrogate");
+
+ let predicted = DirectoryDomains::predict(wtf(&path)).expect("a local directory");
+
+ assert_eq!(predicted.domains(), expected_for(&parent.0).as_slice());
+ fs::remove_dir(&path).expect("remove");
+}
+
+#[test]
+fn a_file_created_in_the_directory_confirms_the_prediction() {
+ let directory = TempDirectory::new("confirm");
+ let predicted = DirectoryDomains::predict(wtf(&directory.0)).expect("predict");
+ let path = directory.0.join("a-file");
+ File::create(&path).expect("create");
+
+ let file = OpenedFile::::open(wtf(&path), FileOptions::new().read().write())
+ .expect("open the file");
+
+ predicted
+ .confirm(&file)
+ .expect("the file is where the directory is");
+ drop(file);
+}
+
+#[test]
+fn a_file_whose_domains_differ_from_the_prediction_is_reported_with_both() {
+ let temp = TempFile::new("dd-mismatch");
+ let file = temp.open();
+ let elsewhere = DirectoryDomains {
+ directory: wtf(Path::new(r"C:\elsewhere")),
+ domains: vec![disk_flush_domain(99)],
+ };
+
+ let mismatch = elsewhere.confirm(&file).expect_err("a different disk");
+
+ assert_eq!(mismatch.predicted, [disk_flush_domain(99)]);
+ assert_eq!(mismatch.found, file.flush_domains());
+ let message = mismatch.to_string();
+ assert!(message.contains("not the ones predicted"), "{message}");
+ // Both sets, so a mismatch can be diagnosed from the message alone.
+ assert!(
+ message.contains(&format!("predicted {:?}", mismatch.predicted)),
+ "{message}"
+ );
+ assert!(
+ message.contains(&format!("found {:?}", mismatch.found)),
+ "{message}"
+ );
+ let _: &dyn std::error::Error = &mismatch;
+}
+
+#[test]
+fn a_prediction_that_differs_by_one_extra_domain_is_a_mismatch() {
+ // A prediction is made by the same derivation as a file's domains, so anything that is not the
+ // same bytes in the same order is a mismatch, and not a near miss.
+ let temp = TempFile::new("dd-extra");
+ let file = temp.open();
+ let mut more = file.flush_domains().to_vec();
+ more.push(FlushDomain::new(b"extra".to_vec()));
+ let wider = DirectoryDomains {
+ directory: wtf(Path::new(r"C:\wider")),
+ domains: more,
+ };
+
+ assert!(wider.confirm(&file).is_err());
+}
+
+#[test]
+fn a_directory_that_does_not_exist_is_the_opens_own_error() {
+ let directory = TempDirectory::new("absent");
+ let absent = directory.0.join("not-there");
+
+ let error = DirectoryDomains::predict(wtf(&absent)).expect_err("no such directory");
+
+ assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}");
+}
+
+#[test]
+fn a_prediction_that_cannot_be_made_is_an_error_and_never_an_empty_set() {
+ let temp = TempFile::new("dd-broken");
+ let handle = SharedHandle::from(File::open(&temp.0).expect("open"));
+
+ let error = DirectoryDomains::from_handle(&Broken, wtf(&temp.0), &handle)
+ .expect_err("every step fails");
+
+ assert!(error.to_string().contains("no volume"), "{error}");
+}
+
+/// The administrative share of this volume, `\\localhost\X$\...`, naming `local`.
+fn admin_share(local: &Path) -> PathBuf {
+ let text = local.to_str().expect("a UTF-8 temp path");
+ let plain = text.strip_prefix(r"\\?\").unwrap_or(text);
+ let drive = plain.chars().next().expect("a drive letter");
+ PathBuf::from(format!(r"\\localhost\{drive}${}", &plain[2..]))
+}
+
+// Named to begin as the share test of `file_domains` does, so the CI step that runs that test by
+// name runs this one too.
+#[test]
+#[ignore = "needs the administrative share \\\\localhost\\C$, which a CI runner has"]
+fn a_file_on_a_share_has_no_domains_found_so_a_local_prediction_does_not_confirm_it() {
+ let directory = TempDirectory::new("share");
+ let predicted = DirectoryDomains::predict(wtf(&directory.0)).expect("the local directory");
+ let path = directory.0.join("a-file");
+ File::create(&path).expect("create");
+
+ let file =
+ OpenedFile::::open(wtf(&admin_share(&path)), FileOptions::new().read().write())
+ .expect("the same file, through the share");
+ let mismatch = predicted
+ .confirm(&file)
+ .expect_err("the file through the share found none");
+ assert!(mismatch.found.is_empty());
+ assert_eq!(mismatch.predicted, predicted.domains());
+
+ // Measured on 2026-11-05: a remote handle's final path in GUID form is refused by the call itself
+ // (ERROR_PATH_NOT_FOUND) rather than given in another form; either way no prediction is made.
+ let error = DirectoryDomains::predict(wtf(&admin_share(&directory.0)))
+ .expect_err("a directory on a share is not predicted");
+ eprintln!("a directory on a share: {error}");
+ drop(file);
+}
diff --git a/crates/win-durable-ioring/src/windows_backend/file_domains.rs b/crates/win-durable-ioring/src/windows_backend/file_domains.rs
new file mode 100644
index 000000000..30bcaf735
--- /dev/null
+++ b/crates/win-durable-ioring/src/windows_backend/file_domains.rs
@@ -0,0 +1,257 @@
+// Copyright (c) 2026 Mike Grier
+//! The flush domains of a file this crate opened, found from the opened handle when it is opened
+//! (DI-D-117).
+//!
+//! A local file's domains are the disks of its volume, each the same [`disk_flush_domain`] bytes a
+//! raw volume derives, so a file and a volume on one disk share fate by construction. A disk whose
+//! bus type is `BusTypeFileBackedVirtual` is followed through the volume's storage dependency to the
+//! volume that holds its backing file, and on from there; its own disk is kept as well, since an
+//! extra domain is safe.
+//!
+//! **All or nothing.** Any step that fails -- a handle with no volume GUID, as a remote file's has
+//! none; a volume that does not answer the extents control; a disk that does not describe itself; a
+//! virtual disk whose host cannot be named -- leaves the file with no domains found. None is unknown,
+//! which shares fate with every target, the safe default; a partial set is the one way a declaration
+//! makes the contract false, so one is never produced.
+//!
+//! **A snapshot.** The domains are those of the moment of the open; a volume later extended onto
+//! another disk is not seen.
+//!
+//! The steps are a trait so the rule that walks them can be tested without the devices: no virtual
+//! disk can be attached here without an elevated token, so that hop is tested against a stack built
+//! in memory, and the real stack through files on this machine's own volumes.
+
+use std::collections::BTreeSet;
+use std::io;
+use std::os::windows::ffi::OsStrExt;
+
+use durable_ioring::{FlushDomain, NoData};
+use win_shared_os_owned_handle::SharedHandle;
+use win_volume_sys::{
+ StorageBusType, StorageDependencyFlags, StorageDependencyVersion,
+ storage_dependency_information, storage_device_descriptor, volume_disk_extents,
+};
+use windows_namespace_request_sys::{FinalPathFlags, QueryFinalPath};
+
+use super::opened_file::Cached;
+use super::opened_volume::{VolumePath, disk_flush_domain};
+use super::raw_open;
+use super::share_mode::ShareMode;
+
+#[cfg(test)]
+mod tests;
+
+/// How many volumes one derivation visits before it stops: a virtual disk nested this deep is not
+/// one this crate has a reason to expect, and a loop in the dependencies must end.
+const MAX_VOLUMES: usize = 16;
+
+/// What the derivation asks of the storage stack.
+pub(super) trait StorageStack {
+ /// The volume the file `file` is on.
+ fn volume_of(&self, file: &SharedHandle) -> io::Result;
+ /// The numbers of the disks the volume `volume` sits on.
+ fn disks_of(&self, volume: &VolumePath) -> io::Result>;
+ /// Whether disk `disk` is backed by a file: its bus type is `BusTypeFileBackedVirtual`.
+ fn is_file_backed(&self, disk: u32) -> io::Result;
+ /// The volumes that hold the files backing the virtual disks under `volume`.
+ fn hosts_of(&self, volume: &VolumePath) -> io::Result>;
+}
+
+/// The flush domains of `file`, or the reason none could be found; [`found`] is the all-or-nothing
+/// form the open uses.
+pub(super) fn derive(
+ stack: &impl StorageStack,
+ file: &SharedHandle,
+) -> io::Result> {
+ let disks = walk(stack, vec![stack.volume_of(file)?])?;
+ Ok(disks.into_iter().map(disk_flush_domain).collect())
+}
+
+/// The flush domains of a raw volume or disk already open, read from its own handle and not from
+/// its path again, which a drive letter reassigned since the open would send elsewhere: `own` are
+/// the disks the handle reports, and `hosts` asks the same handle for the volumes holding the backing
+/// files of any virtual disk among them. Those volumes are followed as a file's are (DI-D-117), so a
+/// raw target on a virtual disk shares fate with a file beside the disk's backing file.
+pub(super) fn seeded(
+ stack: &impl StorageStack,
+ own: &[u32],
+ hosts: impl FnOnce() -> io::Result>,
+) -> io::Result> {
+ if own.is_empty() {
+ return Err(io::Error::other("the target sits on no disk"));
+ }
+ let mut backed = false;
+ for &disk in own {
+ backed |= stack.is_file_backed(disk)?;
+ }
+ let mut disks: Vec = own.to_vec();
+ if backed {
+ let hosts = hosts()?;
+ if hosts.is_empty() {
+ return Err(io::Error::other(
+ "the target is on a virtual disk whose backing file's volume is not named",
+ ));
+ }
+ disks.extend(walk(stack, hosts)?);
+ }
+ disks.sort_unstable();
+ disks.dedup();
+ Ok(disks.into_iter().map(disk_flush_domain).collect())
+}
+
+/// [`seeded`], all or nothing, as [`found`] is for a file.
+pub(super) fn seeded_found(
+ stack: &impl StorageStack,
+ own: &[u32],
+ hosts: impl FnOnce() -> io::Result>,
+) -> Vec {
+ seeded(stack, own, hosts).unwrap_or_default()
+}
+
+/// The volumes holding the backing files of the virtual disks under the volume or disk `opened`,
+/// asked of `opened` itself; `disk` says it is a disk and not a volume.
+pub(super) fn hosts_of_handle(opened: &std::fs::File, disk: bool) -> io::Result> {
+ let flags = if disk {
+ StorageDependencyFlags::HOST_VOLUMES | StorageDependencyFlags::DISK_HANDLE
+ } else {
+ StorageDependencyFlags::HOST_VOLUMES
+ };
+ hosts_named(opened, flags)
+}
+
+/// Every disk under `volumes`, following each virtual disk to the volumes that hold its backing
+/// file, until nothing is left to follow; in disk order, once each.
+fn walk(stack: &impl StorageStack, volumes: Vec) -> io::Result> {
+ let mut disks = BTreeSet::new();
+ let mut visited = BTreeSet::new();
+ let mut pending = volumes;
+ // Counted as followed, and not as the size of `visited`, so the bound holds by itself.
+ let mut followed = 0_usize;
+ while let Some(volume) = pending.pop() {
+ if !visited.insert(volume.as_str().to_owned()) {
+ continue;
+ }
+ followed += 1;
+ if followed > MAX_VOLUMES {
+ return Err(io::Error::other(
+ "the virtual disks under the file nest deeper than this crate follows",
+ ));
+ }
+ let on = stack.disks_of(&volume)?;
+ if on.is_empty() {
+ return Err(io::Error::other(format!(
+ "the volume {} sits on no disk",
+ volume.as_str()
+ )));
+ }
+ let mut backed = false;
+ for disk in on {
+ disks.insert(disk);
+ backed |= stack.is_file_backed(disk)?;
+ }
+ if backed {
+ let hosts = stack.hosts_of(&volume)?;
+ if hosts.is_empty() {
+ return Err(io::Error::other(format!(
+ "the volume {} is on a virtual disk whose backing file's volume is not named",
+ volume.as_str()
+ )));
+ }
+ pending.extend(hosts);
+ }
+ }
+ Ok(disks.into_iter().collect())
+}
+
+/// The flush domains of `file`, all or nothing: those [`derive`](fn@derive) finds, or none.
+pub(super) fn found(stack: &impl StorageStack, file: &SharedHandle) -> Vec {
+ derive(stack, file).unwrap_or_default()
+}
+
+/// The storage stack of this machine.
+pub(super) struct Windows;
+
+impl StorageStack for Windows {
+ fn volume_of(&self, file: &SharedHandle) -> io::Result {
+ let path = QueryFinalPath::new(file.clone())
+ .with_flags(FinalPathFlags::VOLUME_NAME_GUID)
+ .perform()
+ .map_err(io::Error::other)?;
+ // Only the prefix is read: the rest of the path may hold any UTF-16, a lone surrogate
+ // included, which says nothing about the volume.
+ let path: Vec = path.to_os_string().encode_wide().collect();
+ volume_prefix(&path).ok_or_else(|| {
+ io::Error::other(format!(
+ "{} does not begin with a volume GUID",
+ String::from_utf16_lossy(&path)
+ ))
+ })
+ }
+
+ fn disks_of(&self, volume: &VolumePath) -> io::Result> {
+ // Not through `OpenedVolume::open`, which derives a volume's domains with this.
+ let opened = raw_open::open::(volume.as_str(), ShareMode::ALL)?;
+ Ok(volume_disk_extents(&opened)?
+ .iter()
+ .map(|extent| extent.disk_number)
+ .collect())
+ }
+
+ fn is_file_backed(&self, disk: u32) -> io::Result {
+ let opened =
+ raw_open::open::(format!(r"\\.\PhysicalDrive{disk}"), ShareMode::ALL)?;
+ Ok(storage_device_descriptor(&opened)?.bus_type == StorageBusType::FILE_BACKED_VIRTUAL)
+ }
+
+ fn hosts_of(&self, volume: &VolumePath) -> io::Result> {
+ let opened = raw_open::open::(volume.as_str(), ShareMode::ALL)?;
+ hosts_named(&opened, StorageDependencyFlags::HOST_VOLUMES)
+ }
+}
+
+/// The host volumes the storage dependency of `opened` names, asked with `flags`.
+fn hosts_named(
+ opened: &std::fs::File,
+ flags: StorageDependencyFlags,
+) -> io::Result> {
+ let info = storage_dependency_information(opened, flags, StorageDependencyVersion::V2)?;
+ info.entries()
+ .iter()
+ .map(|entry| {
+ let name: Vec = entry
+ .host_volume_name()
+ .ok_or_else(|| io::Error::other("a dependency that names no host volume"))?
+ .to_os_string()
+ .encode_wide()
+ .collect();
+ host_volume(&name).ok_or_else(|| {
+ io::Error::other(format!(
+ "{} is not a volume this crate can open",
+ String::from_utf16_lossy(&name)
+ ))
+ })
+ })
+ .collect()
+}
+
+/// The volume a path in `VOLUME_NAME_GUID` form begins with: `\\?\Volume{...}` and nothing after
+/// it.
+fn volume_prefix(path: &[u16]) -> Option {
+ let end = path.iter().position(|&unit| unit == u16::from(b'}'))?;
+ VolumePath::guid(&String::from_utf16(&path[..=end]).ok()?).ok()
+}
+
+/// A host volume as the dependency names it: a volume GUID path, or a drive letter's root. No
+/// entry has been observed (WV-D-10), so both documented-looking forms are taken and any other is
+/// refused, which leaves the file unknown.
+fn host_volume(name: &[u16]) -> Option {
+ if let Some(volume) = volume_prefix(name) {
+ return Some(volume);
+ }
+ let name = String::from_utf16(name).ok()?;
+ let mut chars = name.chars();
+ match (chars.next(), chars.next(), chars.as_str()) {
+ (Some(letter), Some(':'), "" | "\\") => VolumePath::drive(letter).ok(),
+ _ => None,
+ }
+}
diff --git a/crates/win-durable-ioring/src/windows_backend/file_domains/tests.rs b/crates/win-durable-ioring/src/windows_backend/file_domains/tests.rs
new file mode 100644
index 000000000..e973c00f7
--- /dev/null
+++ b/crates/win-durable-ioring/src/windows_backend/file_domains/tests.rs
@@ -0,0 +1,650 @@
+// Copyright (c) 2026 Mike Grier
+//! Tests for a file's flush domains.
+//!
+//! The rule is tested against a storage stack built in memory, which can hold what this machine
+//! cannot: a virtual disk, one nested in another, a loop, and a failure at each step. The Windows
+//! stack is tested through files on this machine's own volumes, against what a raw volume derives.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::ffi::OsString;
+use std::fs::File;
+use std::io;
+use std::os::windows::ffi::OsStringExt;
+use std::path::{Path, PathBuf};
+
+use durable_ioring::{FlushDomain, IoTarget, NoData};
+use win_shared_os_owned_handle::SharedHandle;
+
+use super::{
+ StorageStack, Windows, derive, found, host_volume, seeded, seeded_found, volume_prefix,
+};
+use crate::dioring::tests::{TempFile, wtf};
+use crate::scratch_volume::{ScratchVolume, scratch_directory};
+use crate::windows_backend::{
+ Cached, FileOptions, OpenedFile, OpenedVolume, Unbuffered, VolumeOptions, VolumePath,
+ Win32FileTarget, disk_flush_domain,
+};
+
+fn volume(n: u32) -> VolumePath {
+ VolumePath::guid(&format!(r"\\?\Volume{{00000000-0000-0000-0000-{n:012x}}}")).expect("a GUID")
+}
+
+/// A storage stack held in memory. The file is on `file_volume`; anything not in a map fails.
+#[derive(Default)]
+struct Stack {
+ file_volume: Option,
+ disks: BTreeMap>,
+ backed: BTreeSet,
+ hosts: BTreeMap>,
+ disk_hosts: BTreeMap>,
+ disk_fails: BTreeSet,
+}
+
+impl Stack {
+ fn on(volume: VolumePath) -> Self {
+ Self {
+ file_volume: Some(volume),
+ ..Self::default()
+ }
+ }
+
+ fn with_disks(mut self, volume: &VolumePath, disks: &[u32]) -> Self {
+ self.disks
+ .insert(volume.as_str().to_owned(), disks.to_vec());
+ self
+ }
+
+ fn backed_by_file(mut self, disk: u32) -> Self {
+ self.backed.insert(disk);
+ self
+ }
+
+ fn hosted_on(mut self, volume: &VolumePath, hosts: &[VolumePath]) -> Self {
+ self.hosts
+ .insert(volume.as_str().to_owned(), hosts.to_vec());
+ self
+ }
+
+ fn disk_hosted_on(mut self, disk: u32, hosts: &[VolumePath]) -> Self {
+ self.disk_hosts.insert(disk, hosts.to_vec());
+ self
+ }
+}
+
+fn missing(what: &str) -> io::Error {
+ io::Error::other(what.to_owned())
+}
+
+impl StorageStack for Stack {
+ fn volume_of(&self, _file: &SharedHandle) -> io::Result {
+ self.file_volume.clone().ok_or_else(|| missing("no volume"))
+ }
+
+ fn disks_of(&self, volume: &VolumePath) -> io::Result> {
+ self.disks
+ .get(volume.as_str())
+ .cloned()
+ .ok_or_else(|| missing("no extents"))
+ }
+
+ fn is_file_backed(&self, disk: u32) -> io::Result {
+ if self.disk_fails.contains(&disk) {
+ return Err(missing("no descriptor"));
+ }
+ Ok(self.backed.contains(&disk))
+ }
+
+ fn hosts_of(&self, volume: &VolumePath) -> io::Result> {
+ self.hosts
+ .get(volume.as_str())
+ .cloned()
+ .ok_or_else(|| missing("no dependency"))
+ }
+}
+
+impl Stack {
+ /// The dependency query asked of raw disk `disk`'s own handle.
+ fn hosts_of_disk(&self, disk: u32) -> io::Result> {
+ self.disk_hosts
+ .get(&disk)
+ .cloned()
+ .ok_or_else(|| missing("no disk dependency"))
+ }
+}
+
+/// A raw volume opened at `volume`: its disks are the extents its handle reports, and its handle is
+/// asked for its hosts, as `OpenedVolume::open` does.
+fn volume_domains(stack: &Stack, volume: &VolumePath) -> io::Result> {
+ let own = stack.disks_of(volume)?;
+ seeded(stack, &own, || stack.hosts_of(volume))
+}
+
+fn volume_found(stack: &Stack, volume: &VolumePath) -> Vec {
+ volume_domains(stack, volume).unwrap_or_default()
+}
+
+/// Raw disk `disk` opened, its handle asked for its hosts, as `OpenedDisk::open` does.
+fn disk_domains(stack: &Stack, disk: u32) -> io::Result> {
+ seeded(stack, &[disk], || stack.hosts_of_disk(disk))
+}
+
+fn disk_found(stack: &Stack, disk: u32) -> Vec {
+ seeded_found(stack, &[disk], || stack.hosts_of_disk(disk))
+}
+
+/// `text` as UTF-16, the form the names arrive in.
+fn utf16(text: &str) -> Vec {
+ text.encode_utf16().collect()
+}
+
+/// A lone high surrogate: valid in a Windows name, not in Unicode.
+const LONE: u16 = 0xD800;
+
+/// Any handle: the stack in memory does not look at it.
+fn any_handle() -> (SharedHandle, TempFile) {
+ let temp = TempFile::new("fd-any");
+ let file = File::create(&temp.0).expect("create");
+ (SharedHandle::from(file), temp)
+}
+
+fn domains(disks: &[u32]) -> Vec {
+ disks.iter().copied().map(disk_flush_domain).collect()
+}
+
+// --- The rule, in memory ----------------------------------------------------------------------
+
+#[test]
+fn a_file_on_plain_disks_has_one_domain_per_disk_once_each_in_disk_order() {
+ let (handle, _temp) = any_handle();
+ for (disks, expected) in [
+ (vec![0], vec![0]),
+ (vec![2, 0, 2, 1], vec![0, 1, 2]),
+ (vec![7, 3], vec![3, 7]),
+ ] {
+ let stack = Stack::on(volume(1)).with_disks(&volume(1), &disks);
+ assert_eq!(
+ derive(&stack, &handle).expect("plain disks"),
+ domains(&expected),
+ "{disks:?}"
+ );
+ }
+}
+
+#[test]
+fn a_virtual_disk_adds_the_disks_of_the_volume_holding_its_backing_file_and_keeps_its_own() {
+ let (handle, _temp) = any_handle();
+ // The file is on volume 1, which sits on disk 5, a VHD; the VHD's file is on volume 2, on disk 0.
+ let stack = Stack::on(volume(1))
+ .with_disks(&volume(1), &[5])
+ .backed_by_file(5)
+ .hosted_on(&volume(1), &[volume(2)])
+ .with_disks(&volume(2), &[0]);
+
+ assert_eq!(derive(&stack, &handle).expect("followed"), domains(&[0, 5]));
+}
+
+#[test]
+fn a_raw_volume_on_a_virtual_disk_shares_fate_with_the_volume_holding_its_backing_file() {
+ // The raw volume is volume 1, on disk 5, a VHD whose file is on volume 2, on disk 0: a file on
+ // volume 2 is on disk 0, so the raw volume must carry disk 0 too.
+ let stack = Stack::default()
+ .with_disks(&volume(1), &[5])
+ .backed_by_file(5)
+ .hosted_on(&volume(1), &[volume(2)])
+ .with_disks(&volume(2), &[0]);
+
+ assert_eq!(
+ volume_domains(&stack, &volume(1)).expect("followed"),
+ domains(&[0, 5])
+ );
+ assert_eq!(volume_found(&stack, &volume(1)), domains(&[0, 5]));
+}
+
+#[test]
+fn a_raw_volume_on_plain_disks_has_its_disks_and_is_not_asked_for_dependencies() {
+ let stack = Stack::default().with_disks(&volume(1), &[3, 1, 3]);
+ assert!(stack.hosts.is_empty());
+ assert_eq!(volume_found(&stack, &volume(1)), domains(&[1, 3]));
+}
+
+#[test]
+fn a_raw_volume_whose_virtual_disk_cannot_be_followed_has_no_domains() {
+ let no_dependency = Stack::default()
+ .with_disks(&volume(1), &[5])
+ .backed_by_file(5);
+ let mut no_descriptor = Stack::default().with_disks(&volume(1), &[5]);
+ no_descriptor.disk_fails.insert(5);
+ for (stack, what) in [
+ (no_dependency, "the dependency query"),
+ (no_descriptor, "the descriptor"),
+ (Stack::default(), "the extents"),
+ ] {
+ assert!(volume_domains(&stack, &volume(1)).is_err(), "{what}");
+ assert!(volume_found(&stack, &volume(1)).is_empty(), "{what}");
+ }
+}
+
+#[test]
+fn a_raw_disk_that_is_virtual_is_followed_through_its_own_dependency() {
+ // Disk 5 is a VHD whose file is on volume 2, on disk 0, whose own VHD file is on volume 3.
+ let stack = Stack::default()
+ .backed_by_file(5)
+ .disk_hosted_on(5, &[volume(2)])
+ .with_disks(&volume(2), &[4])
+ .backed_by_file(4)
+ .hosted_on(&volume(2), &[volume(3)])
+ .with_disks(&volume(3), &[0]);
+
+ assert_eq!(
+ disk_domains(&stack, 5).expect("followed"),
+ domains(&[0, 4, 5])
+ );
+ assert_eq!(disk_found(&stack, 5), domains(&[0, 4, 5]));
+}
+
+#[test]
+fn a_raw_target_is_derived_from_its_handle_and_never_from_its_path_again() {
+ // The handle reports disk 5, a VHD, and names volume 2 as its host. The stack's own idea of
+ // volume 1 -- what its path would resolve to now -- is a different disk, and is never consulted.
+ let stack = Stack::default()
+ .with_disks(&volume(1), &[9])
+ .hosted_on(&volume(1), &[volume(3)])
+ .with_disks(&volume(3), &[8])
+ .backed_by_file(5)
+ .with_disks(&volume(2), &[0]);
+ assert_eq!(
+ seeded(&stack, &[5], || Ok(vec![volume(2)])).expect("from the handle"),
+ domains(&[0, 5])
+ );
+ // A plain disk never asks the handle.
+ assert_eq!(
+ seeded(
+ &Stack::default(),
+ &[3, 1, 3],
+ || -> io::Result> {
+ panic!("a plain disk's handle is not asked for its hosts")
+ }
+ )
+ .expect("plain"),
+ domains(&[1, 3])
+ );
+ // A handle reporting no disk, or no host for its virtual disk, has none.
+ assert!(
+ seeded(&stack, &[], || Ok(vec![volume(2)])).is_err(),
+ "no disk"
+ );
+ assert!(seeded(&stack, &[5], || Ok(Vec::new())).is_err(), "no host");
+ assert!(
+ seeded_found(&stack, &[5], || Err(missing("refused"))).is_empty(),
+ "refused"
+ );
+}
+
+#[test]
+fn a_raw_plain_disk_is_itself_and_is_not_asked_for_dependencies() {
+ let stack = Stack::default();
+ assert!(stack.disk_hosts.is_empty());
+ assert_eq!(disk_found(&stack, 7), domains(&[7]));
+}
+
+#[test]
+fn a_raw_virtual_disk_that_cannot_be_followed_has_no_domains() {
+ let no_dependency = Stack::default().backed_by_file(5);
+ let no_hosts = Stack::default().backed_by_file(5).disk_hosted_on(5, &[]);
+ let host_no_extents = Stack::default()
+ .backed_by_file(5)
+ .disk_hosted_on(5, &[volume(2)]);
+ let mut no_descriptor = Stack::default();
+ no_descriptor.disk_fails.insert(5);
+ for (stack, what) in [
+ (no_dependency, "the dependency query"),
+ (no_hosts, "a dependency naming no host"),
+ (host_no_extents, "the host volume's extents"),
+ (no_descriptor, "the descriptor"),
+ ] {
+ assert!(disk_domains(&stack, 5).is_err(), "{what}");
+ assert!(disk_found(&stack, 5).is_empty(), "{what}");
+ }
+}
+
+#[test]
+fn a_virtual_disk_inside_a_virtual_disk_is_followed_to_the_bottom() {
+ let (handle, _temp) = any_handle();
+ let stack = Stack::on(volume(1))
+ .with_disks(&volume(1), &[5])
+ .backed_by_file(5)
+ .hosted_on(&volume(1), &[volume(2)])
+ .with_disks(&volume(2), &[4])
+ .backed_by_file(4)
+ .hosted_on(&volume(2), &[volume(3)])
+ .with_disks(&volume(3), &[0, 1]);
+
+ assert_eq!(
+ derive(&stack, &handle).expect("followed twice"),
+ domains(&[0, 1, 4, 5])
+ );
+}
+
+#[test]
+fn a_virtual_disk_with_several_hosts_takes_all_of_them_and_a_host_reached_twice_counts_once() {
+ let (handle, _temp) = any_handle();
+ let stack = Stack::on(volume(1))
+ .with_disks(&volume(1), &[5])
+ .backed_by_file(5)
+ .hosted_on(&volume(1), &[volume(2), volume(3), volume(2)])
+ .with_disks(&volume(2), &[0])
+ .with_disks(&volume(3), &[1]);
+
+ assert_eq!(derive(&stack, &handle).expect("both"), domains(&[0, 1, 5]));
+}
+
+#[test]
+fn a_loop_in_the_dependencies_ends() {
+ let (handle, _temp) = any_handle();
+ let stack = Stack::on(volume(1))
+ .with_disks(&volume(1), &[5])
+ .backed_by_file(5)
+ .hosted_on(&volume(1), &[volume(2)])
+ .with_disks(&volume(2), &[6])
+ .backed_by_file(6)
+ .hosted_on(&volume(2), &[volume(1)]);
+
+ assert_eq!(derive(&stack, &handle).expect("ends"), domains(&[5, 6]));
+}
+
+#[test]
+fn a_chain_deeper_than_the_crate_follows_is_unknown() {
+ let (handle, _temp) = any_handle();
+ let mut stack = Stack::on(volume(0));
+ for n in 0..=u32::try_from(super::MAX_VOLUMES).expect("small") {
+ stack = stack
+ .with_disks(&volume(n), &[100 + n])
+ .backed_by_file(100 + n)
+ .hosted_on(&volume(n), &[volume(n + 1)]);
+ }
+
+ assert!(derive(&stack, &handle).is_err());
+ assert!(found(&stack, &handle).is_empty());
+
+ // And one short of the limit is followed to the end.
+ let mut shallow = Stack::on(volume(0));
+ let last = u32::try_from(super::MAX_VOLUMES - 1).expect("small");
+ for n in 0..last {
+ shallow = shallow
+ .with_disks(&volume(n), &[100 + n])
+ .backed_by_file(100 + n)
+ .hosted_on(&volume(n), &[volume(n + 1)]);
+ }
+ shallow = shallow.with_disks(&volume(last), &[0]);
+ assert_eq!(
+ found(&shallow, &handle).len(),
+ usize::try_from(last).expect("small") + 1
+ );
+}
+
+#[test]
+fn a_failure_at_any_step_leaves_no_domains_and_never_a_partial_set() {
+ let (handle, _temp) = any_handle();
+ let whole = || {
+ Stack::on(volume(1))
+ .with_disks(&volume(1), &[5, 2])
+ .backed_by_file(5)
+ .hosted_on(&volume(1), &[volume(2)])
+ .with_disks(&volume(2), &[0])
+ };
+ assert_eq!(found(&whole(), &handle), domains(&[0, 2, 5]), "the control");
+
+ let mut no_volume = whole();
+ no_volume.file_volume = None;
+ let mut no_extents = whole();
+ no_extents.disks.remove(volume(1).as_str());
+ let mut host_no_extents = whole();
+ host_no_extents.disks.remove(volume(2).as_str());
+ let mut no_descriptor = whole();
+ no_descriptor.disk_fails.insert(2);
+ let mut no_dependency = whole();
+ no_dependency.hosts.clear();
+ let no_hosts = whole().hosted_on(&volume(1), &[]);
+ let on_no_disk = whole().with_disks(&volume(2), &[]);
+
+ for (stack, what) in [
+ (no_volume, "the file's volume"),
+ (no_extents, "the volume's extents"),
+ (host_no_extents, "the host volume's extents"),
+ (no_descriptor, "a disk's descriptor"),
+ (no_dependency, "the dependency query"),
+ (no_hosts, "a dependency naming no host"),
+ (on_no_disk, "a volume on no disk"),
+ ] {
+ assert!(derive(&stack, &handle).is_err(), "{what}");
+ assert!(found(&stack, &handle).is_empty(), "{what}");
+ }
+}
+
+#[test]
+fn a_volume_on_no_virtual_disk_is_never_asked_for_its_dependencies() {
+ // The dependency query's answer for a volume that is not on a virtual disk is undocumented, so
+ // it is never asked: a stack whose dependency query fails still derives plain disks.
+ let (handle, _temp) = any_handle();
+ let stack = Stack::on(volume(1)).with_disks(&volume(1), &[0, 1]);
+ assert!(stack.hosts.is_empty());
+
+ assert_eq!(derive(&stack, &handle).expect("plain"), domains(&[0, 1]));
+}
+
+// --- Reading the names --------------------------------------------------------------------------
+
+#[test]
+fn a_final_path_in_guid_form_names_its_volume_and_nothing_else_does() {
+ let guid = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}";
+ for path in [
+ guid.to_owned(),
+ format!(r"{guid}\"),
+ format!(r"{guid}\dir\file.txt"),
+ ] {
+ assert_eq!(
+ volume_prefix(&utf16(&path)).expect("a volume").as_str(),
+ guid,
+ "{path}"
+ );
+ }
+ // A path whose name holds a lone surrogate still names its volume: only the prefix is read.
+ let mut odd = utf16(&format!(r"{guid}\dir\"));
+ odd.extend([LONE, u16::from(b'x')]);
+ assert_eq!(volume_prefix(&odd).expect("a volume").as_str(), guid);
+ let mut inside = utf16(r"\\?\Volume{0123");
+ inside.push(LONE);
+ inside.extend(utf16(r"4567-89ab-cdef-0123-456789abcdef}\file"));
+ assert!(volume_prefix(&inside).is_none(), "a surrogate in the GUID");
+ for path in [
+ r"\\?\UNC\server\share\file",
+ r"C:\file",
+ r"\\?\C:\file",
+ r"\\?\Volume{not-a-guid}\file",
+ "",
+ ] {
+ assert!(volume_prefix(&utf16(path)).is_none(), "{path}");
+ }
+}
+
+#[test]
+fn a_host_volume_is_a_guid_path_or_a_drive_root_and_nothing_else() {
+ let guid = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}";
+ assert_eq!(
+ host_volume(&utf16(&format!(r"{guid}\")))
+ .expect("guid")
+ .as_str(),
+ guid
+ );
+ assert_eq!(
+ host_volume(&utf16(r"C:\")).expect("drive").as_str(),
+ r"\\.\C:"
+ );
+ assert_eq!(
+ host_volume(&utf16("d:")).expect("drive").as_str(),
+ r"\\.\D:"
+ );
+ for name in [r"C:\dir", "C", r"CC:\", r"\\server\share", r"1:\", ""] {
+ assert!(host_volume(&utf16(name)).is_none(), "{name}");
+ }
+ assert!(host_volume(&[u16::from(b'C'), u16::from(b':'), LONE]).is_none());
+}
+
+// --- This machine -------------------------------------------------------------------------------
+
+/// The volume `path` is on, by its drive letter.
+fn drive_of(path: &Path) -> VolumePath {
+ let text = path.to_str().expect("a UTF-8 temp path");
+ let plain = text.strip_prefix(r"\\?\").unwrap_or(text);
+ VolumePath::drive(plain.chars().next().expect("a drive")).expect("a drive letter")
+}
+
+fn expected_for(path: &Path) -> Vec {
+ OpenedVolume::::open(&drive_of(path), VolumeOptions::new())
+ .expect("open the volume with no data access")
+ .flush_domains()
+}
+
+#[test]
+fn a_local_file_has_the_domains_its_volume_derives() {
+ let temp = TempFile::new("fd-local");
+ let file = File::create(&temp.0).expect("create");
+
+ let derived = derive(&Windows, &SharedHandle::from(file)).expect("a local file");
+
+ assert!(!derived.is_empty());
+ assert_eq!(derived, expected_for(&temp.0));
+}
+
+#[test]
+fn a_file_opened_by_this_crate_carries_its_domains_into_its_target_in_either_mode() {
+ let temp = TempFile::new("fd-opened");
+ File::create(&temp.0).expect("create");
+ let expected = expected_for(&temp.0);
+
+ let cached = OpenedFile::::open(wtf(&temp.0), FileOptions::new().read().write())
+ .expect("cached");
+ assert_eq!(cached.flush_domains(), expected.as_slice());
+ let target = Win32FileTarget::new(cached);
+ assert_eq!(target.flush_domains(), expected.as_slice());
+
+ let unbuffered =
+ OpenedFile::::open(wtf(&temp.0), FileOptions::new().read().write())
+ .expect("unbuffered");
+ assert_eq!(unbuffered.flush_domains(), expected.as_slice());
+}
+
+#[test]
+fn a_file_whose_name_is_not_unicode_still_has_its_volumes_domains() {
+ // A lone surrogate is a valid Windows name and not Unicode; only the volume prefix of the final
+ // path is read, so it changes nothing.
+ let base = TempFile::new("fd-surrogate");
+ let mut name = base.0.file_name().expect("a name").to_owned();
+ name.push(OsString::from_wide(&[0xD800]));
+ let temp = TempFile(base.0.with_file_name(name));
+ File::create(&temp.0).expect("create a file named with a lone surrogate");
+
+ let opened =
+ OpenedFile::::open(wtf(&temp.0), FileOptions::new().read().write()).expect("open");
+
+ assert!(!opened.flush_domains().is_empty());
+ assert_eq!(opened.flush_domains(), expected_for(&base.0).as_slice());
+}
+
+#[test]
+fn a_declared_domain_adds_to_the_ones_found_and_removes_none() {
+ let temp = TempFile::new("fd-declared");
+ File::create(&temp.0).expect("create");
+ let expected = expected_for(&temp.0);
+
+ let opened =
+ OpenedFile::::open(wtf(&temp.0), FileOptions::new().read().write()).expect("open");
+ let extra = FlushDomain::new(b"a consumer's own".to_vec());
+ let target = Win32FileTarget::new(opened).with_domains([extra.clone(), expected[0].clone()]);
+
+ let reported = target.flush_domains();
+ for domain in &expected {
+ assert!(reported.contains(domain), "{domain:?} found is kept");
+ }
+ assert!(reported.contains(&extra), "the declared one is added");
+ assert_eq!(
+ reported.len(),
+ expected.len() + 1,
+ "each once: {reported:?}"
+ );
+}
+
+/// The administrative share of this volume, `\\localhost\X$\...`, naming `local`.
+fn admin_share(local: &Path) -> PathBuf {
+ let text = local.to_str().expect("a UTF-8 temp path");
+ let plain = text.strip_prefix(r"\\?\").unwrap_or(text);
+ let drive = plain.chars().next().expect("a drive letter");
+ PathBuf::from(format!(r"\\localhost\{drive}${}", &plain[2..]))
+}
+
+#[test]
+#[ignore = "needs the administrative share \\\\localhost\\C$, which a CI runner has"]
+fn a_file_on_a_share_has_no_domains_found_even_when_the_share_is_this_machine() {
+ let temp = TempFile::new("fd-remote");
+ File::create(&temp.0).expect("create");
+
+ let opened = OpenedFile::::open(
+ wtf(&admin_share(&temp.0)),
+ FileOptions::new().read().write(),
+ )
+ .expect("a cached file on a share");
+
+ assert!(
+ derive(&Windows, opened.handle()).is_err(),
+ "a remote handle's final path names no volume"
+ );
+ assert!(opened.flush_domains().is_empty());
+ let target = Win32FileTarget::new(opened);
+ assert!(target.flush_domains().is_empty(), "unknown");
+
+ // A consumer that knows where the share's storage is declares it, and with none found that is
+ // the file's whole set: here, honestly, this machine's own disks.
+ let local = expected_for(&temp.0);
+ let declared = target.with_domains(local.clone());
+ assert_eq!(declared.flush_domains(), local.as_slice());
+}
+
+#[test]
+#[ignore = "needs an administrator: creates and attaches a virtual disk"]
+fn a_raw_volume_on_a_scratch_virtual_disk_is_file_backed_and_names_the_volume_its_file_is_on() {
+ // The one hop no developer machine shows: a real virtual disk, whose file is in the temporary
+ // directory. Its disk must describe itself as file-backed, and the dependency must name the
+ // volume that holds the file, whose disks are the ones the derivation then adds.
+ let scratch = ScratchVolume::create(64).expect("a scratch volume");
+ let volume = scratch.path();
+
+ let disks = Windows
+ .disks_of(&volume)
+ .expect("the scratch volume's extents");
+ assert_eq!(disks.len(), 1, "one virtual disk: {disks:?}");
+ assert!(
+ Windows
+ .is_file_backed(disks[0])
+ .expect("the virtual disk describes itself"),
+ "a VHD's disk is BusTypeFileBackedVirtual"
+ );
+
+ let hosts = Windows.hosts_of(&volume).expect("the dependency query");
+ assert!(!hosts.is_empty(), "the dependency names a host");
+ let expected_disks = Windows
+ .disks_of(&drive_of(&scratch_directory()))
+ .expect("the temporary directory's volume");
+ let mut host_disks = Vec::new();
+ for host in &hosts {
+ host_disks.extend(Windows.disks_of(host).expect("a host volume's extents"));
+ }
+ host_disks.sort_unstable();
+ host_disks.dedup();
+ let mut expected = expected_disks;
+ expected.sort_unstable();
+ expected.dedup();
+ assert_eq!(
+ host_disks, expected,
+ "the hosts are the volume the VHD file is on: {hosts:?}"
+ );
+}
diff --git a/crates/win-durable-ioring/src/windows_backend/file_target.rs b/crates/win-durable-ioring/src/windows_backend/file_target.rs
index 21c6348eb..70954ac68 100644
--- a/crates/win-durable-ioring/src/windows_backend/file_target.rs
+++ b/crates/win-durable-ioring/src/windows_backend/file_target.rs
@@ -37,13 +37,21 @@ use durable_ioring::{Access, Capabilities, Geometry, HasData, IoTarget, Warranty
/// let _ = Win32FileTarget::new(no_data);
/// ```
///
-/// A volume's flush domains are found from its disks. A file's are what it cannot know, so they are
-/// declared with [`with_domains`](Self::with_domains); declaring them on a volume adds to the ones
-/// found, and never removes one.
+/// A volume's and a disk's flush domains are found when this crate opened them
+/// ([`OpenedVolume::flush_domains`], [`OpenedDisk::flush_domains`]), and a file's from its handle
+/// ([`OpenedFile::flush_domains`], DI-D-117): the disks it is on and, for a virtual disk among them,
+/// those of the volume holding its backing file, all or nothing, so a
+/// file on a share, or one whose volume could not be read, has none found and shares fate with every
+/// target. [`with_domains`](Self::with_domains) adds what Windows does not show, and never removes one
+/// found: the disks of a Storage Spaces pool, which two spaces in one pool may share; the disks behind
+/// a hardware RAID controller; and a cache a controller shares between disks. On a file with none
+/// found, such as one on a share, what is declared is its whole set: no key is derived for a share,
+/// since a server's name is not its identity and a share can be this machine's own disk, so a
+/// consumer that knows where a share's storage is says so.
#[derive(Clone, Debug)]
pub struct Win32FileTarget {
handle: SharedHandle,
- /// What the backing found for itself: a volume's disks.
+ /// What the backing found for itself: the disks of a volume, or of a file's volume.
found: Vec,
/// What the consumer declared.
declared: Vec,
@@ -60,20 +68,21 @@ impl Win32FileTarget {
/// The target `file` makes, claiming what its open mode allows and no more.
#[must_use]
pub fn new(file: OpenedFile) -> Self {
+ let found = file.flush_domains().to_vec();
Self {
geometry: file.geometry(),
handle: file.handle().clone(),
- found: Vec::new(),
+ domains: found.clone(),
+ found,
declared: Vec::new(),
- domains: Vec::new(),
warranty: B::WARRANTY,
capabilities: B::CAPABILITIES,
access: A::ACCESS,
}
}
- /// The target `volume` makes: its sector's alignment, the flush domains of the disks it sits
- /// on, and what its open mode allows and no more.
+ /// The target `volume` makes: its sector's alignment, the flush domains found when it was opened
+ /// ([`OpenedVolume::flush_domains`]), and what its open mode allows and no more.
#[must_use]
pub fn from_volume(volume: OpenedVolume) -> Self {
let found = volume.flush_domains();
@@ -89,11 +98,11 @@ impl Win32FileTarget {
}
}
- /// The target `disk` makes: its sector's alignment, the disk itself as its flush domain, and
- /// what its open mode allows and no more.
+ /// The target `disk` makes: its sector's alignment, the flush domains found when it was opened
+ /// ([`OpenedDisk::flush_domains`]), and what its open mode allows and no more.
#[must_use]
pub fn from_disk(disk: OpenedDisk) -> Self {
- let found = vec![disk.flush_domain()];
+ let found = disk.flush_domains().to_vec();
Self {
geometry: disk.geometry(),
handle: disk.handle().clone(),
@@ -162,6 +171,16 @@ impl Win32FileTarget {
self
}
+ /// The target with what the backing found set aside, for a test that uses temporary files, all on
+ /// one disk, to stand for files on separate devices: their found disk would join them, as it
+ /// should for real files, and the scenario would not be the one the test means.
+ #[cfg(test)]
+ pub(crate) fn without_found_domains(mut self) -> Self {
+ self.found.clear();
+ self.domains = self.declared.clone();
+ self
+ }
+
/// Declare every flush domain its writes depend on, beyond any the backing found for itself.
/// Declaring again replaces what was declared before, and never what the backing found.
#[must_use]
diff --git a/crates/win-durable-ioring/src/windows_backend/opened_disk.rs b/crates/win-durable-ioring/src/windows_backend/opened_disk.rs
index 3a8203a79..424bf1449 100644
--- a/crates/win-durable-ioring/src/windows_backend/opened_disk.rs
+++ b/crates/win-durable-ioring/src/windows_backend/opened_disk.rs
@@ -21,6 +21,7 @@ use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, RawHandle};
use win_shared_os_owned_handle::SharedHandle;
use win_volume_sys::bytes_per_sector;
+use super::file_domains;
use super::opened_file::{Buffering, Unbuffered};
use super::opened_volume::disk_flush_domain;
use super::raw_open;
@@ -106,6 +107,7 @@ pub struct OpenedDisk {
handle: SharedHandle,
geometry: Geometry,
disk_number: u32,
+ domains: Vec,
mode: PhantomData<(B, A)>,
}
@@ -126,10 +128,16 @@ impl OpenedDisk {
let sector = bytes_per_sector(&file)?;
let geometry = B::sector_geometry(sector)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ let disk_number = path.disk_number();
+ // Asked of this handle, not of `path` again.
+ let domains = file_domains::seeded_found(&file_domains::Windows, &[disk_number], || {
+ file_domains::hosts_of_handle(&file, true)
+ });
Ok(Self {
handle: SharedHandle::from(file),
geometry,
- disk_number: path.disk_number(),
+ disk_number,
+ domains,
mode: PhantomData,
})
}
@@ -152,12 +160,22 @@ impl OpenedDisk {
self.disk_number
}
- /// The disk's flush domain: itself.
+ /// The disk's own flush domain: itself.
#[must_use]
pub fn flush_domain(&self) -> FlushDomain {
disk_flush_domain(self.disk_number)
}
+ /// The flush domains of the disk, found when it was opened: itself, and, if it is a virtual disk
+ /// backed by a file, those of the volume that holds the file, followed as a file's are
+ /// (DI-D-117); once each, in disk order. A file on the host volume and this disk then share fate,
+ /// as they do. When any step of following fails the disk has none, which is unknown and shares
+ /// fate with every target.
+ #[must_use]
+ pub fn flush_domains(&self) -> &[FlushDomain] {
+ &self.domains
+ }
+
/// A disk over `handle`, with the parts a test gives it, so that what is derived from a disk
/// can be tried without opening one, which needs an administrator.
#[cfg(test)]
@@ -166,6 +184,7 @@ impl OpenedDisk {
handle,
geometry: B::sector_geometry(sector).expect("a power of two"),
disk_number,
+ domains: vec![disk_flush_domain(disk_number)],
mode: PhantomData,
}
}
diff --git a/crates/win-durable-ioring/src/windows_backend/opened_file.rs b/crates/win-durable-ioring/src/windows_backend/opened_file.rs
index 25ec2053e..3685d686c 100644
--- a/crates/win-durable-ioring/src/windows_backend/opened_file.rs
+++ b/crates/win-durable-ioring/src/windows_backend/opened_file.rs
@@ -38,17 +38,19 @@ use windows_sys::Win32::Storage::FileSystem::{
use wtf_string::{Wtf16Str, Wtf16String};
use durable_ioring::{
- Access, Capabilities, DataAccess, Geometry, GeometryError, NoData, ReadWrite, WarrantyClass,
+ Access, Capabilities, DataAccess, FlushDomain, Geometry, GeometryError, NoData, ReadWrite,
+ WarrantyClass,
};
use super::disposition::Disposition;
use super::file_attributes::FileAttributes;
+use super::file_domains;
use super::file_id::FileIdentifier;
use super::file_rights::FileRights;
use super::share_mode::ShareMode;
mod by_id;
-mod open_request;
+pub(in crate::windows_backend) mod open_request;
mod reopen;
pub use by_id::{ByIdOptions, ByIdRequest, IdentityMismatch};
@@ -637,6 +639,8 @@ fn refine_sharing_by(
pub struct OpenedFile {
handle: SharedHandle,
geometry: Geometry,
+ /// The flush domains found from the handle when it was opened, all or nothing (DI-D-117).
+ domains: Vec,
mode: PhantomData<(B, A)>,
}
@@ -702,6 +706,20 @@ impl OpenedFile {
pub fn geometry(&self) -> Geometry {
self.geometry
}
+
+ /// The flush domains found from the handle when the file was opened: one for each disk its volume
+ /// sits on, the same [`disk_flush_domain`](super::disk_flush_domain) bytes a raw volume derives,
+ /// with a virtual disk followed to the disks of the volume that holds its backing file (DI-D-117).
+ ///
+ /// **All or nothing.** When any step fails -- as it does for a file on a share, whose final path
+ /// names no volume -- this is empty, which is unknown and shares fate with every target, and never
+ /// a partial set. **A snapshot** of the moment of the open: a volume later extended onto another
+ /// disk is not seen. What Windows does not show is the consumer's to add with
+ /// [`Win32FileTarget::with_domains`](super::Win32FileTarget::with_domains).
+ #[must_use]
+ pub fn flush_domains(&self) -> &[FlushDomain] {
+ &self.domains
+ }
}
/// The request to open one file, built by [`OpenedFile::request`] and made by
@@ -793,9 +811,11 @@ impl OpenedFile {
));
}
}
+ let domains = file_domains::found(&file_domains::Windows, &handle);
Ok(OpenedFile {
handle,
geometry: B::geometry(),
+ domains,
mode: PhantomData,
})
}
diff --git a/crates/win-durable-ioring/src/windows_backend/opened_file/open_request.rs b/crates/win-durable-ioring/src/windows_backend/opened_file/open_request.rs
index 3b961b769..9ba58f99b 100644
--- a/crates/win-durable-ioring/src/windows_backend/opened_file/open_request.rs
+++ b/crates/win-durable-ioring/src/windows_backend/opened_file/open_request.rs
@@ -23,7 +23,7 @@ mod tests;
/// What an open asks of `CreateFileW`, already checked for the combinations that cannot mean
/// anything.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub(super) struct Parameters {
+pub(in crate::windows_backend) struct Parameters {
/// `dwDesiredAccess`.
pub access: u32,
/// `dwCreationDisposition`.
@@ -37,7 +37,10 @@ pub(super) struct Parameters {
/// The request to open `path` as `parameters` says, built on the calling thread. A path with an
/// interior NUL is refused as invalid input, and a relative one is fixed to the current directory as
/// it is now, before anything is touched.
-pub(super) fn request(path: Wtf16String, parameters: Parameters) -> io::Result {
+pub(in crate::windows_backend) fn request(
+ path: Wtf16String,
+ parameters: Parameters,
+) -> io::Result {
Ok(OpenFile::new(path)
.map_err(path_error)?
.with_desired_access(parameters.access)
@@ -47,7 +50,7 @@ pub(super) fn request(path: Wtf16String, parameters: Parameters) -> io::Result io::Result {
+pub(in crate::windows_backend) fn perform(request: &OpenFile) -> io::Result {
request
.perform()
.map_err(|error| io::Error::from_raw_os_error(raw_code(error.code())))
diff --git a/crates/win-durable-ioring/src/windows_backend/opened_volume.rs b/crates/win-durable-ioring/src/windows_backend/opened_volume.rs
index ae374f17f..4fc3c1bb2 100644
--- a/crates/win-durable-ioring/src/windows_backend/opened_volume.rs
+++ b/crates/win-durable-ioring/src/windows_backend/opened_volume.rs
@@ -22,6 +22,7 @@ use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, RawHandle};
use win_shared_os_owned_handle::SharedHandle;
use win_volume_sys::{DiskExtent, bytes_per_sector, lock_volume, volume_disk_extents};
+use super::file_domains;
use super::opened_file::{Buffering, Unbuffered};
use super::raw_open;
use super::share_mode::ShareMode;
@@ -187,6 +188,7 @@ pub struct OpenedVolume {
handle: SharedHandle,
geometry: Geometry,
extents: Vec,
+ domains: Vec,
locked: bool,
mode: PhantomData<(B, A)>,
}
@@ -211,6 +213,12 @@ impl OpenedVolume {
let sector = bytes_per_sector(&file)?;
let geometry = B::sector_geometry(sector)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ // From this handle and its extents, not from `path` again, which may name another volume by
+ // now; before the lock, which would refuse the opens the walk past a virtual disk makes.
+ let own: Vec = extents.iter().map(|extent| extent.disk_number).collect();
+ let domains = file_domains::seeded_found(&file_domains::Windows, &own, || {
+ file_domains::hosts_of_handle(&file, false)
+ });
if options.lock {
lock_volume(&file)?;
}
@@ -218,6 +226,7 @@ impl OpenedVolume {
handle: SharedHandle::from(file),
geometry,
extents,
+ domains,
locked: options.lock,
mode: PhantomData,
})
@@ -241,10 +250,14 @@ impl OpenedVolume {
&self.extents
}
- /// The flush domains of the volume: one for each disk it sits on, once each, in disk order.
+ /// The flush domains of the volume, found when it was opened: one for each disk it sits on,
+ /// and, for a disk that is a virtual disk backed by a file, those of the volume that holds the
+ /// file, followed as a file's are (DI-D-117); once each, in disk order. A file on the host volume
+ /// and this volume then share fate, as they do. When any step of following fails the volume has
+ /// none, which is unknown and shares fate with every target.
#[must_use]
pub fn flush_domains(&self) -> Vec {
- domains_of(&self.extents)
+ self.domains.clone()
}
/// Whether the volume was locked when it was opened.
@@ -265,6 +278,7 @@ impl OpenedVolume {
Self {
handle,
geometry: B::sector_geometry(sector).expect("a power of two"),
+ domains: domains_of(&extents),
extents,
locked,
mode: PhantomData,
@@ -285,6 +299,7 @@ impl AsRawHandle for OpenedVolume {
}
/// One flush domain for each distinct disk in `extents`, in disk order.
+#[cfg(test)]
fn domains_of(extents: &[DiskExtent]) -> Vec {
let mut disks: Vec = extents.iter().map(|extent| extent.disk_number).collect();
disks.sort_unstable();
diff --git a/crates/win-durable-ioring/src/windows_backend/opened_volume/tests.rs b/crates/win-durable-ioring/src/windows_backend/opened_volume/tests.rs
index a91e3a826..05b38581b 100644
--- a/crates/win-durable-ioring/src/windows_backend/opened_volume/tests.rs
+++ b/crates/win-durable-ioring/src/windows_backend/opened_volume/tests.rs
@@ -340,12 +340,19 @@ fn declared_domains_are_added_to_a_volumes_and_never_take_one_away() {
}
#[test]
-fn a_file_targets_domains_are_only_what_is_declared() {
+fn a_file_targets_domains_are_its_volumes_disks_and_what_is_declared() {
+ // DI-D-117: a file this crate opened has its volume's disks found, as a volume has, and a
+ // declaration adds to them.
let temp = TempFile::new("ov-file-domains");
let file =
OpenedFile::::open(wtf(&temp.0), FileOptions::new().read().write()).expect("open");
+ let mut expected = file.flush_domains().to_vec();
+ assert!(!expected.is_empty(), "a local file's disks are found");
let target = Win32FileTarget::new(file).with_domains([disk_flush_domain(9)]);
- assert_eq!(target.flush_domains(), [disk_flush_domain(9)]);
+ if !expected.contains(&disk_flush_domain(9)) {
+ expected.push(disk_flush_domain(9));
+ }
+ assert_eq!(target.flush_domains(), expected.as_slice());
}
#[test]
diff --git a/crates/win-durable-ioring/src/windows_backend/tests.rs b/crates/win-durable-ioring/src/windows_backend/tests.rs
index dcf0661d9..f2cc25654 100644
--- a/crates/win-durable-ioring/src/windows_backend/tests.rs
+++ b/crates/win-durable-ioring/src/windows_backend/tests.rs
@@ -554,10 +554,16 @@ fn attach_refuses_a_key_in_use_and_detach_hands_the_target_back() {
#[test]
fn a_file_target_claims_what_its_open_mode_allows() {
let file = TempFile::new("wb-target");
- let cached = Win32FileTarget::new(file.open());
+ let opened = file.open();
+ let found = opened.flush_domains().to_vec();
+ assert!(
+ !found.is_empty(),
+ "a local file's disks are found (DI-D-117)"
+ );
+ let cached = Win32FileTarget::new(opened);
assert_eq!(cached.warranty(), WarrantyClass::BestEffort);
assert_eq!(cached.geometry(), Geometry::UNALIGNED);
- assert!(cached.flush_domains().is_empty());
+ assert_eq!(cached.flush_domains(), found.as_slice());
assert!(!cached.capabilities().write_through());
let unbuffered = Win32FileTarget::new(
@@ -566,10 +572,9 @@ fn a_file_target_claims_what_its_open_mode_allows() {
)
.with_domains([FlushDomain::new(b"disk".as_slice())]);
let description = unbuffered.describe();
- assert_eq!(
- description.flush_domains,
- [FlushDomain::new(b"disk".as_slice())]
- );
+ let mut expected = found.clone();
+ expected.push(FlushDomain::new(b"disk".as_slice()));
+ assert_eq!(description.flush_domains, expected);
assert_eq!(description.warranty, WarrantyClass::SharedDevice);
assert_eq!(
description.geometry,
@@ -591,7 +596,9 @@ fn an_attested_file_targets_is_complete_and_nothing_else_changes() {
assert_eq!(attested.warranty(), WarrantyClass::Complete);
assert_eq!(plain.warranty(), WarrantyClass::SharedDevice);
- assert_eq!(attested.flush_domains(), [domain]);
+ let mut expected = open().flush_domains().to_vec();
+ expected.push(domain);
+ assert_eq!(attested.flush_domains(), expected.as_slice());
assert_eq!(attested.flush_domains(), plain.flush_domains());
assert_eq!(attested.geometry(), plain.geometry());
assert_eq!(
@@ -615,15 +622,24 @@ fn an_attested_file_target_reports_complete_to_the_core() {
#[test]
fn declaring_a_file_targets_domains_again_replaces_them() {
- // Carried over from `FileOptions`, which the target replaced: a declaration is the whole set.
+ // Carried over from `FileOptions`, which the target replaced: a declaration is the whole of what
+ // is declared, and never removes what was found for the file (DI-D-117).
let file = TempFile::new("wb-domains");
let domain = |name: &[u8]| FlushDomain::new(name.to_vec());
- let target = Win32FileTarget::new(file.open())
+ let opened = file.open();
+ let found = opened.flush_domains().to_vec();
+ let target = Win32FileTarget::new(opened)
.with_domains([domain(b"A")])
.with_domains([domain(b"B"), domain(b"C")]);
- assert_eq!(target.flush_domains(), [domain(b"B"), domain(b"C")]);
+ let mut expected = found.clone();
+ expected.extend([domain(b"B"), domain(b"C")]);
+ assert_eq!(target.flush_domains(), expected.as_slice());
let cleared = target.with_domains([]);
- assert!(cleared.flush_domains().is_empty(), "none declared again");
+ assert_eq!(
+ cleared.flush_domains(),
+ found.as_slice(),
+ "none declared again"
+ );
}
#[test]
diff --git a/crates/win-volume-sys/README.md b/crates/win-volume-sys/README.md
index fa74bae74..28d57c7e5 100644
--- a/crates/win-volume-sys/README.md
+++ b/crates/win-volume-sys/README.md
@@ -24,6 +24,10 @@ promise, as the workspace's naming decision defines it.
and the NUMA node it is attached to, through `IOCTL_STORAGE_QUERY_PROPERTY`. They take a handle to
a disk (`\\.\PhysicalDriveN`), opened with no access, and report what the device gave: the
identifier's sections as bytes, not parsed, and the node unaltered.
+- **`storage_device_descriptor`** -- what a disk says it is (`StorageDeviceProperty`, the reply
+ `STORAGE_DEVICE_DESCRIPTOR`): its SCSI device type, removable media, command queueing, the bus it
+ is attached by as a `StorageBusType` that keeps any number the documentation does not name, and its
+ vendor, product, revision and serial strings as bytes. Same handle, same access.
- **`query_storage_property`** -- the same control for any property and any kind of query, with
the reply as the bytes the device gave, so a property this crate has no typed function for is one
call away.
diff --git a/crates/win-volume-sys/sabotage.json b/crates/win-volume-sys/sabotage.json
index b8efa1d86..794dbc9e7 100644
--- a/crates/win-volume-sys/sabotage.json
+++ b/crates/win-volume-sys/sabotage.json
@@ -786,6 +786,62 @@
"why": "A control: the structure is three u32s, twelve bytes, so the file changes and no behaviour. A sweep that reports it caught is reporting everything caught and proves nothing.",
"find": [" let mut buffer = [0u8; size_of::()];"],
"replace": [" let mut buffer = [0u8; 12];"]
+ },
+ {
+ "name": "M39.2.2: the descriptor function asks for a different property",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "Another property's descriptor reads without error as a device descriptor; its version, which the page says is the structure's size, is what tells them apart.",
+ "find": [" let input = query_input(StorageDeviceProperty, PropertyStandardQuery, &[])?;"],
+ "replace": [" let input = query_input(StorageDeviceProperty + 1, PropertyStandardQuery, &[])?;"]
+ },
+ {
+ "name": "M39.2.2: the bus type is read from the bus-specific length field",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "Each field is read from where the structure has it, and the bus type is what decides whether a disk is followed to its backing file.",
+ "find": [" bus_type: StorageBusType::from_raw(word(layout::BUS_TYPE)?.cast_signed()),"],
+ "replace": [" bus_type: StorageBusType::from_raw(word(layout::RAW_PROPERTIES_LENGTH)?.cast_signed()),"]
+ },
+ {
+ "name": "M39.2.2: an offset of zero is not read as no string",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "The page: an offset of zero means the device has no such string.",
+ "find": [" if offset == 0 {"],
+ "replace": [" if offset == usize::MAX {"]
+ },
+ {
+ "name": "M39.2.2: a string that starts inside the fields is read",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "A string inside the fixed fields would be read out of the fields themselves; the reply does not hold what its header says.",
+ "find": [" if offset < layout::RAW_PROPERTIES {"],
+ "replace": [" if offset < 1 {"]
+ },
+ {
+ "name": "M39.2.2: a string is carried with its terminator",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "The string is the bytes before its terminator.",
+ "find": [" Ok(Some(rest[..end].to_vec()))"],
+ "replace": [" Ok(Some(rest[..=end].to_vec()))"]
+ },
+ {
+ "name": "M39.2.2: the bus-specific bytes are not bounded by their length",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "The bus-specific bytes are the length the descriptor says, and a length past the reply is refused.",
+ "find": [" .and_then(|end| bytes.get(layout::RAW_PROPERTIES..end))"],
+ "replace": [" .map(|_| &bytes[layout::RAW_PROPERTIES..])"]
+ },
+ {
+ "name": "M39.2.2: BusTypeNvmeof is misnumbered",
+ "file": "src/storage_device.rs",
+ "expect": "caught",
+ "why": "The STORAGE_BUS_TYPE page lists it as 20; windows-sys does not declare it, so the number here is the only statement of it.",
+ "find": [" pub const NVMEOF: Self = Self(20);"],
+ "replace": [" pub const NVMEOF: Self = Self(21);"]
}
]
}
diff --git a/crates/win-volume-sys/src/lib.rs b/crates/win-volume-sys/src/lib.rs
index 60bc78f5e..7104c67ec 100644
--- a/crates/win-volume-sys/src/lib.rs
+++ b/crates/win-volume-sys/src/lib.rs
@@ -10,6 +10,7 @@ mod extents;
mod geometry;
mod lock;
mod storage_dependency;
+mod storage_device;
mod storage_numa;
mod storage_property;
mod storage_query;
@@ -27,6 +28,7 @@ pub use storage_dependency::{
StorageDependencyVersion, VirtualStorageType, storage_dependency_information,
storage_dependency_information_with_capacity,
};
+pub use storage_device::{StorageBusType, StorageDeviceDescriptor, storage_device_descriptor};
pub use storage_numa::{StorageDeviceNuma, storage_device_numa};
pub use storage_query::{StoragePropertyId, StorageQueryType, query_storage_property};
pub use storage_unique_id::{StorageDeviceUniqueId, storage_device_unique_id};
diff --git a/crates/win-volume-sys/src/storage_device.rs b/crates/win-volume-sys/src/storage_device.rs
new file mode 100644
index 000000000..2abf4edca
--- /dev/null
+++ b/crates/win-volume-sys/src/storage_device.rs
@@ -0,0 +1,300 @@
+// Copyright (c) 2026 Mike Grier
+//! What a disk says it is: `IOCTL_STORAGE_QUERY_PROPERTY` for `StorageDeviceProperty`, whose reply
+//! is `STORAGE_DEVICE_DESCRIPTOR`.
+//!
+//! The descriptor carries the device's SCSI type, whether its media is removable, whether it
+//! queues commands, the bus it is attached by, and four optional strings -- vendor, product,
+//! revision and serial number -- appended after the structure, with bus-specific bytes. Everything
+//! is carried **as the device wrote it**: a bus type the documentation does not name keeps its
+//! number, and the strings are the bytes before their terminator, not checked to be ASCII.
+//!
+//! # What the documentation says, and what it does not
+//!
+//! The `STORAGE_DEVICE_DESCRIPTOR` page (read 2026-11-05) says each string offset is "the byte
+//! offset from the beginning of the structure to a null-terminated ASCII string", and that an
+//! offset of zero means the device has none; the reply's size is in its `Size` member. The
+//! `STORAGE_BUS_TYPE` page names its values without describing most of them, so a value's name is
+//! all this crate states about it. That page lists `BusTypeNvmeof` as 20; `windows-sys` 0.61.2
+//! does not have it and calls 20 `BusTypeMax`, so it is named here from the page.
+//!
+//! A string or the bus-specific bytes that would lie outside the reply, a string that starts
+//! inside the structure's fixed fields, or one with no terminator in the reply, is
+//! [`io::ErrorKind::InvalidData`]: those are this crate's checks that a reply holds what its header
+//! says, not conditions the page states.
+
+use std::fmt;
+use std::io;
+use std::mem::offset_of;
+use std::os::windows::io::AsHandle;
+
+use windows_sys::Win32::Storage::FileSystem::{
+ BusType1394, BusTypeAta, BusTypeAtapi, BusTypeFibre, BusTypeFileBackedVirtual, BusTypeMmc,
+ BusTypeNvme, BusTypeRAID, BusTypeSCM, BusTypeSas, BusTypeSata, BusTypeScsi, BusTypeSd,
+ BusTypeSpaces, BusTypeSsa, BusTypeUfs, BusTypeUnknown, BusTypeUsb, BusTypeVirtual,
+ BusTypeiScsi, STORAGE_BUS_TYPE,
+};
+use windows_sys::Win32::System::Ioctl::{
+ PropertyStandardQuery, STORAGE_DEVICE_DESCRIPTOR, StorageDeviceProperty,
+};
+
+use crate::device_control::{invalid_data, u32_at};
+use crate::storage_property::{FIRST_BYTES, query_input, query_property};
+
+#[cfg(test)]
+mod tests;
+
+/// Where the descriptor's fields lie, from the structure `windows-sys` declares.
+mod layout {
+ use super::{STORAGE_DEVICE_DESCRIPTOR, offset_of};
+
+ pub(super) const VERSION: usize = offset_of!(STORAGE_DEVICE_DESCRIPTOR, Version);
+ pub(super) const SIZE: usize = offset_of!(STORAGE_DEVICE_DESCRIPTOR, Size);
+ pub(super) const DEVICE_TYPE: usize = offset_of!(STORAGE_DEVICE_DESCRIPTOR, DeviceType);
+ pub(super) const DEVICE_TYPE_MODIFIER: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, DeviceTypeModifier);
+ pub(super) const REMOVABLE_MEDIA: usize = offset_of!(STORAGE_DEVICE_DESCRIPTOR, RemovableMedia);
+ pub(super) const COMMAND_QUEUEING: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, CommandQueueing);
+ pub(super) const VENDOR_ID_OFFSET: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, VendorIdOffset);
+ pub(super) const PRODUCT_ID_OFFSET: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, ProductIdOffset);
+ pub(super) const PRODUCT_REVISION_OFFSET: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, ProductRevisionOffset);
+ pub(super) const SERIAL_NUMBER_OFFSET: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, SerialNumberOffset);
+ pub(super) const BUS_TYPE: usize = offset_of!(STORAGE_DEVICE_DESCRIPTOR, BusType);
+ pub(super) const RAW_PROPERTIES_LENGTH: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, RawPropertiesLength);
+ /// Where the bus-specific bytes begin, and so where the fixed fields end.
+ pub(super) const RAW_PROPERTIES: usize =
+ offset_of!(STORAGE_DEVICE_DESCRIPTOR, RawDeviceProperties);
+}
+
+/// The bus a disk is attached by, as `STORAGE_BUS_TYPE` numbers it.
+///
+/// A raw-carrying value: the documented values are named, and any other is kept as the device
+/// wrote it. Changing a named value is a breaking change.
+#[derive(Clone, Copy, PartialEq, Eq, Hash)]
+pub struct StorageBusType(STORAGE_BUS_TYPE);
+
+impl StorageBusType {
+ /// `BusTypeUnknown`.
+ pub const UNKNOWN: Self = Self(BusTypeUnknown);
+ /// `BusTypeScsi`.
+ pub const SCSI: Self = Self(BusTypeScsi);
+ /// `BusTypeAtapi`.
+ pub const ATAPI: Self = Self(BusTypeAtapi);
+ /// `BusTypeAta`.
+ pub const ATA: Self = Self(BusTypeAta);
+ /// `BusType1394`.
+ pub const IEEE_1394: Self = Self(BusType1394);
+ /// `BusTypeSsa`.
+ pub const SSA: Self = Self(BusTypeSsa);
+ /// `BusTypeFibre`.
+ pub const FIBRE: Self = Self(BusTypeFibre);
+ /// `BusTypeUsb`.
+ pub const USB: Self = Self(BusTypeUsb);
+ /// `BusTypeRAID`.
+ pub const RAID: Self = Self(BusTypeRAID);
+ /// `BusTypeiScsi`.
+ pub const ISCSI: Self = Self(BusTypeiScsi);
+ /// `BusTypeSas`.
+ pub const SAS: Self = Self(BusTypeSas);
+ /// `BusTypeSata`.
+ pub const SATA: Self = Self(BusTypeSata);
+ /// `BusTypeSd`.
+ pub const SD: Self = Self(BusTypeSd);
+ /// `BusTypeMmc`.
+ pub const MMC: Self = Self(BusTypeMmc);
+ /// `BusTypeVirtual`.
+ pub const VIRTUAL: Self = Self(BusTypeVirtual);
+ /// `BusTypeFileBackedVirtual`.
+ pub const FILE_BACKED_VIRTUAL: Self = Self(BusTypeFileBackedVirtual);
+ /// `BusTypeSpaces`.
+ pub const SPACES: Self = Self(BusTypeSpaces);
+ /// `BusTypeNvme`.
+ pub const NVME: Self = Self(BusTypeNvme);
+ /// `BusTypeSCM`.
+ pub const SCM: Self = Self(BusTypeSCM);
+ /// `BusTypeUfs`.
+ pub const UFS: Self = Self(BusTypeUfs);
+ /// `BusTypeNvmeof`, 20 by the `STORAGE_BUS_TYPE` page; `windows-sys` 0.61.2 does not declare it.
+ pub const NVMEOF: Self = Self(20);
+
+ const NAMED: [(Self, &'static str); 21] = [
+ (Self::UNKNOWN, "BusTypeUnknown"),
+ (Self::SCSI, "BusTypeScsi"),
+ (Self::ATAPI, "BusTypeAtapi"),
+ (Self::ATA, "BusTypeAta"),
+ (Self::IEEE_1394, "BusType1394"),
+ (Self::SSA, "BusTypeSsa"),
+ (Self::FIBRE, "BusTypeFibre"),
+ (Self::USB, "BusTypeUsb"),
+ (Self::RAID, "BusTypeRAID"),
+ (Self::ISCSI, "BusTypeiScsi"),
+ (Self::SAS, "BusTypeSas"),
+ (Self::SATA, "BusTypeSata"),
+ (Self::SD, "BusTypeSd"),
+ (Self::MMC, "BusTypeMmc"),
+ (Self::VIRTUAL, "BusTypeVirtual"),
+ (Self::FILE_BACKED_VIRTUAL, "BusTypeFileBackedVirtual"),
+ (Self::SPACES, "BusTypeSpaces"),
+ (Self::NVME, "BusTypeNvme"),
+ (Self::SCM, "BusTypeSCM"),
+ (Self::UFS, "BusTypeUfs"),
+ (Self::NVMEOF, "BusTypeNvmeof"),
+ ];
+
+ /// A bus type from its number, kept whether or not it is one the documentation names.
+ #[must_use]
+ pub const fn from_raw(raw: STORAGE_BUS_TYPE) -> Self {
+ Self(raw)
+ }
+
+ /// The number.
+ #[must_use]
+ pub const fn raw(self) -> STORAGE_BUS_TYPE {
+ self.0
+ }
+
+ /// The documentation's name for it, or `None` for a number it does not name.
+ #[must_use]
+ pub fn name(self) -> Option<&'static str> {
+ Self::NAMED
+ .iter()
+ .find(|(value, _)| *value == self)
+ .map(|(_, name)| *name)
+ }
+}
+
+impl fmt::Debug for StorageBusType {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self.name() {
+ Some(name) => write!(f, "{name}"),
+ None => write!(f, "StorageBusType({})", self.0),
+ }
+ }
+}
+
+impl fmt::Display for StorageBusType {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ fmt::Debug::fmt(self, f)
+ }
+}
+
+/// What a disk reported in its device descriptor.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct StorageDeviceDescriptor {
+ /// The version of the descriptor, as the device wrote it.
+ pub version: u32,
+ /// The size of the descriptor with what is appended to it, as the device wrote it.
+ pub size: u32,
+ /// The device type, as the SCSI specification numbers it.
+ pub device_type: u8,
+ /// The device type modifier, zero when there is none.
+ pub device_type_modifier: u8,
+ /// Whether the device's media is removable. The page says to ignore it for a device with no
+ /// media.
+ pub removable_media: bool,
+ /// Whether the device takes several commands at once.
+ pub command_queueing: bool,
+ /// The bus the device is attached by.
+ pub bus_type: StorageBusType,
+ /// The vendor ID string's bytes, without its terminator; `None` when the device has none.
+ pub vendor_id: Option>,
+ /// The product ID string's bytes, without its terminator; `None` when the device has none.
+ pub product_id: Option>,
+ /// The product revision string's bytes, without its terminator; `None` when the device has none.
+ pub product_revision: Option>,
+ /// The serial number string's bytes, without its terminator; `None` when the device has none.
+ pub serial_number: Option>,
+ /// The bus-specific bytes appended to the descriptor, which `bus_type` says how to read.
+ pub raw_properties: Vec,
+}
+
+impl StorageDeviceDescriptor {
+ /// Reads `bytes`, which is the whole reply.
+ fn decode(bytes: &[u8]) -> io::Result {
+ if bytes.len() < layout::RAW_PROPERTIES {
+ return Err(invalid_data("a reply shorter than the device descriptor"));
+ }
+ let word = |offset| u32_at(bytes, offset).ok_or_else(|| invalid_data("a field cut short"));
+ let string = |offset_at: usize| -> io::Result