From 3138cc2ca43fd3ed7ca442a412a6ee36b4676c60 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 7 Sep 2026 01:24:00 -0400 Subject: [PATCH] feat(platform-probes): measure the long-path opt-in with a manifested pair Stage 2 of peeling `windows-platform-probes` off `mikegrier/deferred-namespace-ops` (#56), on top of the report sink stage 1 landed. It adds the long-path family: `long_path` (the measurement), `long_path_report` (the shared renderer), and the two binaries whose *difference* is the finding. The question is whether the `longPathAware` opt-in lifts `MAX_PATH` for a **relative** path. It matters here because a library cannot add a manifest to someone else's executable, so whatever the un-opted-in case does is what a caller of this workspace's crates actually meets. Neither half of that opt-in is a runtime switch -- it is a machine-wide registry value and a per-executable manifest -- so it cannot be a flag on one binary. Hence the pair: the same code, built twice, with `build.rs` embedding the manifest into `probe-long-path-aware` alone. `rustc-link-arg-bin` rather than `rustc-link-arg-bins`, because the latter would opt every probe in the crate into long paths and silently change what the others measure. The aware binary reads a `cargo::rustc-cfg` the script emits from that same guarded block rather than asserting its own manifest, so the label and the linker cannot disagree -- on a non-MSVC target the script emits neither, and a hardcoded claim would have had both halves measuring the un-opted-in case while one announced otherwise. Measured on the development host rather than described, and the two halves do disagree, which is what proves the apparatus works at all: plain 429 chars aware: opened unaware: REFUSED (error 3) with `..` 434 chars aware: opened unaware: REFUSED (error 3) forward slashes 429 chars aware: opened unaware: REFUSED (error 3) The aware side also resolved `..` and forward slashes past the ceiling exactly as below it, so on this host there is no evidence of a regularize-then-prefix implementation -- the sharp edge the report is written to catch. **The two binaries were converted to the stage-1 sink, not carried over as they were.** On the branch they still read `emit(&mut Stdout, &render(...))`, which is the shape stage 1 found destroys a report when a renderer panics. So `long_path_report::render` composes the banner and header, then measures, then renders the body -- the ordering is load-bearing, because a panic in the measurement must still print what was already established. The invariant holds at 10 binaries and 10 `emit_report` call sites, with no direct printing anywhere in the crate. **The report never states more than the run established.** That is the whole of `verdict`, and it is why the guards exist rather than being defensive padding: a shape must have opened *below* the ceiling before "stopped resolving past it" can be said of it; the reference shape must have been tried *above* it; a long failure must be a length refusal rather than an access denial; and both halves of the opt-in must be in effect before a refusal is read as contradicting the documentation. Without that last one the un-opted-in binary declared Microsoft's documentation wrong on every correctly configured host, from the half of the pair whose entire job is to be the baseline. `MAX_PATH_CONTENT` is 259, not 260: a path of exactly `MAX_PATH` content units does not fit, because the terminator needs the last one. That is the convention `windows-namespace-request-sys` and `windows-file-enumeration-sys` already state and assert, and the ceiling column prints the number rather than naming it. **A temporary directory over 200 UTF-16 units is refused before anything else runs.** In a `longPathAware` process an over-long `%TMP%`/`%TEMP%` makes both `std::env::temp_dir()` and the `reg.exe` spawn hang rather than fail -- the process spins on a core and prints nothing at all, since the report only reaches stdout when `render` returns. The limit is chosen rather than derived, and sits far enough short of where the hang starts that the exact boundary stops mattering. On that path `registry_enabled` is `None`, not `false`: the query was never issued, and a `bool` cannot say so. Two Cargo.toml changes, both required by the above: - `wtf-string`, for a UTF-16 code-unit count wherever a length is compared against a Windows limit. `OsStr::len` counts WTF-8 here, so the two disagree the moment a non-ASCII character appears in `%TEMP%` and put an attempt on the wrong side of the ceiling. - `Win32_System_Environment`, for `Get`/`SetCurrentDirectoryW`: the current directory is half of what a relative path resolves against, so the probe places it deliberately rather than inheriting whatever launched it. Verified by running, not only by building: - `cargo check --all-targets` on this package alone against `main`'s tree, which establishes the subset is self-contained: the new code reaches only `std`, `windows-sys`, `wtf-string`, and stage 1's `report`. - Both binaries run to completion, exit 0, and disagree as tabled above. - **The manifest reached exactly one binary**, confirmed by searching the linked images for the manifest's own XML. Searching for the bare string `longPathAware` reports it in *both*, because the report's own label contains it -- the weaker test would have raised a false alarm about the central mechanism. - Workspace `clippy --all-targets --all-features` clean; `fmt --check` clean. - 62 package tests pass (61 unit, 1 integration; 14 are the ignored probe tier). The renderer is split so `body` and `verdict` are pure functions of an `Observation`, which is what lets the report's own tests run without a filesystem: each is named for the wrong sentence it forbids and asserts both that the right claim is made and that the wrong one is not. - `check-workflow-refs`, `check-publishable`, `check-commit-scope`, and the encoding check all pass. Both probes are wired into the CI probe job, and deliberately both: either alone reports a number with no baseline to read it against. Design decisions are recorded in the crate's DESIGN-NOTES.md -- the pair and the one-binary embed, the declined concurrency hardening for `measure` (which moves the process's current directory, and cannot be made safe by a unique root because the directory is per-process), and the finding that the ceiling applies to the path *as written* rather than after `..` collapses. That last one is easy to assume backwards and the obvious experiment does not settle it: `cmd.exe` carries `longPathAware` in its own manifest on this host, so a long path that opens there says nothing about the ceiling. Two things a review on the PR caught: - **The table called a not-found past the ceiling "the length refusal" for every shape**, asserting that "the target provably exists". True for plain, which is legal under `\\?\` too -- that is why it is the control. For `..` and forward slashes it is exactly the ambiguity this probe exists to resolve: if the system prepends `\\?\` past the ceiling, those shapes stop being resolved and the path as written names something that was never created, so the not-found is a genuine absence and *is* the sharp edge. Annotating it as the length refusal files the finding as its own control. The annotation is now keyed on `survives_verbatim_parsing`, and says plainly for the other two that the verdict below decides which. - `build_tree`'s returned relative path was bound and then discarded with `let _ = deep_relative;`. Only the side effect is wanted -- each attempt spells its own path, because the spelling is what is under test. A further review round raised two more; one held and one did not. - **A UNC or ill-formed `%TMP%`/`%TEMP%` is now refused.** The apparatus composes its `\\?\` paths by concatenation, and a UNC root needs `\\?\UNC\server\share` rather than the prefix glued onto `\\server`; separately, `Path::display` substitutes U+FFFD for an unpaired surrogate, which would name a different file than the one created. Both are refused up front for the same reason the length is. Supporting UNC properly is three lines, but no machine this workspace is developed or tested on could exercise it, and an untested path through the apparatus is worth less than an honest refusal. - **The unquoted `/MANIFESTINPUT:` path was reported as fragile against spaces. It is not, and the suggested fix breaks the build.** Measured rather than argued: pointing `build.rs` at a manifest under `C:\lp spaces test\` builds clean and embeds the manifest correctly, because cargo passes the link argument as one argv element and the linker receives it whole. Adding the suggested quotes fails with `LNK1181: cannot open input file 'spaces.obj'` -- the literal quotes make the linker split on the space it had been handling. Left as it is. A third round raised two more, both in the apparatus rather than in the measurement, and both fixed. - **The machine half of the opt-in is read as a number, not as a substring.** `reg query`'s output was tested with `.contains("0x1")`. The reported example -- `0x10` matching -- does not by itself change the answer, since a nonzero value is enabled on either reading; the failure that costs something is the mirror one. A machine set to `0x2` contains no `0x1` and read as *not* opted in, which inverts the conclusion the whole probe exists to reach. The value token is now parsed and compared numerically, and the reading is split from the `reg.exe` spawn so it is tested against captured output rather than against whatever the developing machine happens to be set to. - **`\\?\` and `\\.\` are no longer reported as UNC, and the encoding is classified before any prefix is read.** Both open with the two backslashes a UNC root does, so the UNC test claimed them and told someone with an ordinary local temporary directory that it was a server share. They are still refused, but for their own reason: `\\?\` turns off the path normalisation this probe exists to measure, so a run rooted there would measure the verbatim ceiling and label it the ordinary one. The well-formedness check now runs first, because the prefix tests read the value as text, and reading an ill-formed value as text is the exact substitution that refusal exists to prevent. The `/MANIFESTINPUT:` suggestion was raised again and is unchanged, for the reason measured above. Deliberately excluded, so this stays reviewable: the four other modules `lib.rs` gains on the branch, the remaining probe binaries, and the topology tests in `src/tests.rs`. Those are later stages and each depends on this one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 9 + CHECKLIST-mutation-survivors.md | 3 +- Cargo.lock | 1 + crates/windows-platform-probes/CHECKLIST.md | 4 +- crates/windows-platform-probes/Cargo.toml | 28 + .../windows-platform-probes/DESIGN-NOTES.md | 69 ++ crates/windows-platform-probes/build.rs | 50 ++ .../long-path-aware.manifest | 9 + .../src/bin/long_path_aware.rs | 32 + .../src/bin/long_path_unaware.rs | 25 + crates/windows-platform-probes/src/lib.rs | 3 + .../windows-platform-probes/src/long_path.rs | 706 ++++++++++++++++++ .../src/long_path/tests.rs | 363 +++++++++ .../src/long_path_report.rs | 419 +++++++++++ .../src/long_path_report/tests.rs | 656 ++++++++++++++++ tools/check-encoding.ps1 | 6 +- 16 files changed, 2379 insertions(+), 4 deletions(-) create mode 100644 crates/windows-platform-probes/build.rs create mode 100644 crates/windows-platform-probes/long-path-aware.manifest create mode 100644 crates/windows-platform-probes/src/bin/long_path_aware.rs create mode 100644 crates/windows-platform-probes/src/bin/long_path_unaware.rs create mode 100644 crates/windows-platform-probes/src/long_path.rs create mode 100644 crates/windows-platform-probes/src/long_path/tests.rs create mode 100644 crates/windows-platform-probes/src/long_path_report.rs create mode 100644 crates/windows-platform-probes/src/long_path_report/tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15e1d540..46fc4c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,15 @@ jobs: run: cargo run -p windows-platform-probes --bin probe-ioring --locked - name: probe magnitudes (completion port) run: cargo run -p windows-platform-probes --bin probe-completion-port --locked + # Both halves of the long-path pair, deliberately. Either alone says + # nothing: the finding is the *difference* between two executables that + # differ only in whether `build.rs` embedded the `longPathAware` manifest, + # so a run that reported one of them would be reporting a number with no + # baseline to read it against. + - name: probe magnitudes (long path, manifest-aware) + run: cargo run -p windows-platform-probes --bin probe-long-path-aware --locked + - name: probe magnitudes (long path, manifest-unaware) + run: cargo run -p windows-platform-probes --bin probe-long-path-unaware --locked # The NUMA questions the 2026-08-30 design session could not answer, run # against whatever machine the runner fleet supplies. diff --git a/CHECKLIST-mutation-survivors.md b/CHECKLIST-mutation-survivors.md index 25fb7403..8938b33d 100644 --- a/CHECKLIST-mutation-survivors.md +++ b/CHECKLIST-mutation-survivors.md @@ -92,7 +92,8 @@ to the engineer, not to whoever picks up this checklist. tracked as `SH-13.4` on the branch that ships the topology and queue crates. The sink has since landed here ([crates/windows-platform-probes/src/report.rs](crates/windows-platform-probes/src/report.rs)) - and all eight probes in this repository route through it, so what remains of + and every probe in this repository routes through it -- stated without a count, + because the count moves as stages land -- so what remains of `SH-13.4` is the six branch-only probes, and it stays named rather than linked because that branch checklist is still not in this repository. The crate's own durable work now has a home to link: diff --git a/Cargo.lock b/Cargo.lock index 176b4850..55c81d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,7 @@ dependencies = [ "windows-placement-probe", "windows-sys", "windows-threadpool-sys", + "wtf-string", ] [[package]] diff --git a/crates/windows-platform-probes/CHECKLIST.md b/crates/windows-platform-probes/CHECKLIST.md index ea01dcd0..a69a2369 100644 --- a/crates/windows-platform-probes/CHECKLIST.md +++ b/crates/windows-platform-probes/CHECKLIST.md @@ -29,7 +29,7 @@ piece of work rather than a correction to that one. - [ ] **M1.1** -- Decide how a formatted line reaches the sink, because that choice is what makes the rest mechanical. Every renderer today writes through `let _ = writeln!(out, ...)` against a - `String`'s `fmt::Write` -- roughly 156 sites across the eight probes -- so the sink must accept + `String`'s `fmt::Write` -- upwards of 160 sites across the probes -- so the sink must accept *formatted* output, not just `&str`, or every site grows a `format!` and an allocation per line. The options differ in what they cost callers, and the choice is the engineer's: (a) give `Report` a method taking `fmt::Arguments` plus a `report_line!` macro, so a call site stays @@ -41,7 +41,7 @@ piece of work rather than a correction to that one. (c) leave the renderers writing to a `String` and flush it to the sink at each line boundary, which streams without touching the call sites but keeps two buffers. -- [ ] **M1.2** -- Convert the eight renderers to write into the sink as they measure, and simplify +- [ ] **M1.2** -- Convert every renderer to write into the sink as it measures, and simplify `emit_report` accordingly: once lines leave as they are produced, catching the unwind is no longer what makes partial output work, and the `catch_unwind`/`resume_unwind` pair should be removed rather than left as machinery that no longer earns its place. Keep `Captured` working -- it is what every diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index b1a41e4e..d7f7f6e0 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -47,6 +47,17 @@ path = "src/bin/ioring.rs" name = "probe-pool-growth" path = "src/bin/pool_growth.rs" +# These two are the same code, and that is the measurement: they differ only in +# whether `build.rs` embeds the `longPathAware` manifest, which is not a runtime +# switch and so cannot be a flag on one binary. +[[bin]] +name = "probe-long-path-aware" +path = "src/bin/long_path_aware.rs" + +[[bin]] +name = "probe-long-path-unaware" +path = "src/bin/long_path_unaware.rs" + [dependencies] # **Every workspace dependency below is path-only, with no `version`**, because # this crate is never distributed at all -- not to a registry, and not as a @@ -68,6 +79,19 @@ windows-threadpool-sys = { path = "../windows-threadpool-sys" } # somewhere and compared against something it does not describe. That banner is # `windows-placement-probe`'s to render, not a second copy here. windows-placement-probe = { path = "../windows-placement-probe" } +# The long-path probe measures a length against `MAX_PATH`, and `MAX_PATH` counts +# UTF-16 code units. `OsStr::len` counts Rust's platform encoding -- WTF-8 here -- +# so the two disagree the moment a non-ASCII character appears in `%TEMP%`, which +# would put an attempt on the wrong side of the ceiling the probe exists to +# characterize. This crate holds a string in the encoding Windows uses, so its +# `len` is the number under test rather than a conversion of one. +# +# That is the whole of what it is used for here: a code-unit count, at every site +# where a length is compared against a Windows limit. +# `encode_wide().count()` would give the same number, and the choice of this crate +# over that is a vocabulary one -- the workspace's own WTF-16 type naming the +# quantity rather than an ad-hoc count that happens to agree. +wtf-string = { path = "../wtf-string" } [dependencies.windows-sys] version = "0.61.2" @@ -76,6 +100,10 @@ features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + # SetCurrentDirectoryW, for the long-path probe: the current directory is + # half of what a relative path resolves against, so the probe has to place + # it deliberately rather than inherit whatever launched it. + "Win32_System_Environment", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Pipes", diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 5d7b1167..b0b88b74 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -498,3 +498,72 @@ that introduced the sink. The ordering was deliberate. The sink had to exist before the probes could be peeled off their originating branch in reviewable stages, and a design that streams is a different design, not a later revision of this one. + +## The long-path probe: a pair of binaries, and a second declined hardening + + + +The `longPathAware` opt-in has two halves and neither is a runtime switch: a +machine-wide registry value, and a per-executable manifest. Nothing a process can +read off itself tells it whether the manifest half applies, so the question +"does the opt-in lift `MAX_PATH` for a relative path?" cannot be answered by one +binary with a flag. It is answered by two binaries that differ *only* in the +manifest, and the finding is the difference between their reports. + +`build.rs` embeds the manifest with `rustc-link-arg-bin` naming +`probe-long-path-aware` specifically, never `rustc-link-arg-bins`: the plural +form would opt every binary in the crate into long paths and silently change what +all the others measure. The aware binary does not assert its own manifest either; +it reads a `cargo::rustc-cfg` the build script emits from the same guarded block +that does the embedding, so the label and the linker cannot disagree. Before that, +a non-MSVC target skipped the block and produced two binaries with no manifest +between them, one of which still reported `manifest longPathAware : yes` -- the +one failure this probe cannot make loudly, because the whole finding is the +difference between the pair. + +### `measure` moves the process's current directory, and that is the point + + + +A relative path resolves against the current directory, so half of what is under +test is *where the process is*. The probe therefore sets the current directory +deliberately rather than inheriting whatever launched it, restores it in a `Drop` +guard, and removes the tree it built. + +This is the same tension the error-mode probe records in +[The concurrency hardening is knowingly declined](#the-concurrency-hardening-is-knowingly-declined): +a probe may change process-wide state, a component may not. As there, **the +concurrency hardening is knowingly declined.** `measure` is not safe to call +concurrently, and its rustdoc says so. Giving each call a unique root would not +fix it, because the current directory is per-process rather than per-call: two +concurrent runs would still fight over the one thing being measured. The +serialization lives in the tests, which take a mutex, and the binaries are +single-threaded and call `measure` once. + +The declined alternative is worth naming so it is not re-proposed: threading the +directory through as an explicit parameter and never calling +`SetCurrentDirectoryW` would make the function safe, and would also stop it +measuring the thing it exists to measure -- a *relative* path's resolution, which +is defined against the process's current directory and nothing else. + +### The ceiling is applied to the path as written + + + +`MAX_PATH` is compared against the literal path handed to the call, before `..` +is collapsed. That matters here because the `..` shape's literal is five units +longer than its canonical form, so the two readings disagree in a five-unit band +-- and the probe classifies every row on that number. + +Measured rather than assumed, using this crate's own un-manifested binary with +the deep level forced to 21: plain resolved to 258 and **opened**, `..` resolved +to 263 and was **refused**, against a content ceiling of 259. Had the collapse +come first, both would have been 258 and both would have opened. + +The obvious shortcut does not settle this and should not be used. Reaching for +`cmd.exe` measures `cmd`'s manifest, not the un-opted-in case: on the development +host -- Windows 11 build 26200, `cmd.exe` 10.0.26100.1 -- `cmd` carries +`longPathAware` in its own manifest beside `dpiAware`, so a long path that opens +there says nothing about the ceiling. That is a fact about that binary on that +build rather than about `cmd` for all time, which is exactly why the probe rests +on a binary this workspace builds and manifests itself. diff --git a/crates/windows-platform-probes/build.rs b/crates/windows-platform-probes/build.rs new file mode 100644 index 00000000..05963230 --- /dev/null +++ b/crates/windows-platform-probes/build.rs @@ -0,0 +1,50 @@ +// Copyright (c) Mike Grier. + +//! Embeds `longPathAware` into **one** binary, so the long-path opt-in can be +//! measured rather than read about. +//! +//! The opt-in has two halves and neither is a runtime switch: a machine-wide +//! registry value, and a per-executable manifest. The manifest half is what +//! this adds, and it is added to `probe-long-path-aware` **alone** -- +//! `probe-long-path-unaware` is the same code without it, because a comparison +//! needs both sides and the un-opted-in case is what most consumers of this +//! workspace actually have. +//! +//! `rustc-link-arg-bin` rather than `rustc-link-arg-bins`: the latter would +//! opt every probe in this crate into long paths, silently changing what all +//! the others measure -- stated without a count on purpose, so it stays true as +//! probes are added. + +fn main() { + // Declared unconditionally, because the cfg's *absence* is as meaningful as + // its presence and an undeclared name would warn under `unexpected_cfgs`. + println!("cargo::rustc-check-cfg=cfg(long_path_manifest_embedded)"); + + // Only the MSVC linker understands these, and this crate is Windows-only + // anyway; guarding keeps a cross-compile from failing on a flag its linker + // has never heard of. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + let manifest = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("long-path-aware.manifest"); + println!("cargo::rerun-if-changed=long-path-aware.manifest"); + println!("cargo::rustc-link-arg-bin=probe-long-path-aware=/MANIFEST:EMBED"); + println!( + "cargo::rustc-link-arg-bin=probe-long-path-aware=/MANIFESTINPUT:{}", + manifest.display() + ); + + // The aware binary reads this rather than claiming the opt-in outright, + // so its report cannot say `manifest longPathAware : yes` for an + // executable this script did not manifest. + // + // That mattered: with the claim hardcoded, a non-MSVC target skipped the + // block above and produced two binaries with *no* manifest between them, + // one of which still announced it had one. Both halves then measured the + // un-opted-in case and a reader comparing them would conclude the opt-in + // does not work -- a wrong answer, silently, in the one place this probe + // cannot fail loudly instead, because the whole finding is the difference + // between the two. Emitting the flag from the same branch that does the + // embedding is what keeps the label and the linker in step. + println!("cargo::rustc-cfg=long_path_manifest_embedded"); + } +} diff --git a/crates/windows-platform-probes/long-path-aware.manifest b/crates/windows-platform-probes/long-path-aware.manifest new file mode 100644 index 00000000..cca3cfd5 --- /dev/null +++ b/crates/windows-platform-probes/long-path-aware.manifest @@ -0,0 +1,9 @@ + + + + + + true + + + diff --git a/crates/windows-platform-probes/src/bin/long_path_aware.rs b/crates/windows-platform-probes/src/bin/long_path_aware.rs new file mode 100644 index 00000000..b251c5c3 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/long_path_aware.rs @@ -0,0 +1,32 @@ +// Copyright (c) Mike Grier. + +//! Measures the long-path opt-in **with** `longPathAware` in the manifest. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. See this crate's DESIGN-NOTES.md. +//! +//! Its twin, `probe-long-path-unaware`, is the same code without the manifest. +//! Run both: one row of results proves nothing, because the difference between +//! them is the whole measurement. + +use windows_platform_probes::long_path_report; +use windows_platform_probes::report::emit_report; + +fn main() { + // The probe's whole output policy, and it is one line: hand the renderer to + // the sink. Nothing here or below names a stream -- that is chosen once, in + // `report`, so retargeting a probe is not a rewrite. + // + // The manifest is this binary's whole difference from its twin, and it is a + // claim about the build rather than something measured at runtime: the two + // halves of the opt-in are a machine-wide registry value and a per-executable + // manifest, neither of which is a switch a process can read off itself. + // + // So the claim is *derived* from the flag `build.rs` sets in the same branch + // that embeds the manifest, never hardcoded. Hardcoding `true` here would let + // a target whose linker the script skips -- anything non-MSVC -- report + // `manifest longPathAware : yes` for an executable carrying no manifest, + // while both halves quietly measured the same un-opted-in case. + let manifest_aware = cfg!(long_path_manifest_embedded); + emit_report(|out| long_path_report::render(out, manifest_aware)); +} diff --git a/crates/windows-platform-probes/src/bin/long_path_unaware.rs b/crates/windows-platform-probes/src/bin/long_path_unaware.rs new file mode 100644 index 00000000..8cd32d70 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/long_path_unaware.rs @@ -0,0 +1,25 @@ +// Copyright (c) Mike Grier. + +//! Measures the long-path opt-in **without** `longPathAware` in the manifest. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. See this crate's DESIGN-NOTES.md. +//! +//! This is the case most consumers of this workspace actually have, which is +//! why it is measured rather than assumed: a library cannot add a manifest to +//! someone else's executable, so whatever this reports is what a caller who has +//! not opted in will meet. + +use windows_platform_probes::long_path_report; +use windows_platform_probes::report::emit_report; + +fn main() { + // The probe's whole output policy, and it is one line: hand the renderer to + // the sink. Nothing here or below names a stream -- that is chosen once, in + // `report`, so retargeting a probe is not a rewrite. + // + // `false` is this binary's whole difference from its twin: `build.rs` + // embeds the manifest into `probe-long-path-aware` alone, so this one is + // the same code compiled without the opt-in. + emit_report(|out| long_path_report::render(out, false)); +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index bc3e537f..c16c9ebe 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -104,6 +104,7 @@ //! | [`completion_port::measure`] | ignored | IOCP association, and `CreateThreadpoolIo`, foreclose `IoRing` use of a handle | //! | [`cancel_io::cancel_against_idle_thread`] | binary only | `CancelSynchronousIo` is point-in-time against an idle thread | //! | [`cancel_io::cancel_against_busy_thread`] | binary only | it can block indefinitely against a thread re-entering synchronous I/O | +//! | [`long_path::measure`] | binary only | whether the `longPathAware` manifest opt-in lifts `MAX_PATH` for a *relative* path -- binary only because the answer is the difference between two differently-manifested executables, which no single in-process test can observe | #![cfg(windows)] #![forbid(unsafe_op_in_unsafe_fn)] @@ -115,6 +116,8 @@ pub mod device_map; pub mod error_mode; pub mod handle_state; pub mod ioring; +pub mod long_path; +pub mod long_path_report; pub mod pool_growth; pub mod report; pub mod worker_context; diff --git a/crates/windows-platform-probes/src/long_path.rs b/crates/windows-platform-probes/src/long_path.rs new file mode 100644 index 00000000..646d510c --- /dev/null +++ b/crates/windows-platform-probes/src/long_path.rs @@ -0,0 +1,706 @@ +// Copyright (c) Mike Grier. + +//! Does the long-path opt-in lift `MAX_PATH` for a **relative** path, and does +//! it change how that path is parsed? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # The question, and why reading could not settle it +//! +//! Microsoft's *Maximum Path Length Limitation* puts "relative paths are always +//! limited to a total of MAX_PATH characters" inside the `\\?\` **prefix** +//! section, where it is a consequence of that mechanism -- the prefix cannot be +//! applied to a relative path. Its separate long-path opt-in section says the +//! restriction is removed from a list of functions that includes `CreateFileW`, +//! and excludes nothing. So the documented answer is that the opt-in covers +//! relative paths. +//! +//! That reading produced two wrong answers in one PR #56 review cycle, in +//! opposite directions, which is the reason this exists as a measurement. +//! +//! # The hypothesis this is built to falsify +//! +//! A plausible implementation of the opt-in is to regularize the path and +//! prepend `\\?\` before proceeding as usual. That prefix is precisely what +//! disables `.`, `..` and forward-slash translation -- so if that is how it +//! works, a relative path using any of those could resolve **under** `MAX_PATH` +//! and fail **over** it. A discontinuity at a length boundary is the worst kind +//! to meet in production, and no page states it. +//! +//! So each shape is measured at both lengths. A shape that works short and +//! fails long is the sharp edge; a shape that works at both is evidence the +//! opt-in does not re-parse. +//! +//! # Reading the result +//! +//! Run both binaries. `probe-long-path-aware` carries `longPathAware` in its +//! manifest; `probe-long-path-unaware` is the same code without it, because the +//! un-opted-in case is what most consumers of this workspace actually have. +//! The registry half (`LongPathsEnabled`) is a machine setting and is reported +//! rather than assumed, since a result gathered without it says nothing. +//! +//! **The un-opted-in half is a baseline, not a counter-example**, and its report +//! says so. `MAX_PATH` applying to a process that never opted in is what +//! `MAX_PATH` means; only a refusal with *both* halves in effect would bear on +//! the documented reading. The verdict consults both before drawing any +//! conclusion, so the unaware binary reports what it is -- the case the aware one +//! is read against -- rather than announcing a contradiction it did not test. + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ALREADY_EXISTS, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, GetLastError, + INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CREATE_ALWAYS, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +// `SetCurrentDirectoryW` lives under Environment rather than FileSystem, +// because the current directory is per-process environment state rather than a +// file operation. +use windows_sys::Win32::System::Environment::{GetCurrentDirectoryW, SetCurrentDirectoryW}; +use wtf_string::Wtf16String; + +/// Windows's classic path ceiling, **counting the terminating NUL**. +const MAX_PATH: usize = 260; + +/// The longest path content that fits under the ceiling, terminator excluded. +/// +/// The distinction is the whole subject of this probe, so it is spelled out +/// rather than folded into a comparison: a path of exactly `MAX_PATH` content +/// units does *not* fit, because the NUL needs the last one. Comparing against +/// `MAX_PATH` instead would classify that path as under the ceiling while +/// Windows refused it for length -- a row asserting both at once, at exactly the +/// boundary this probe exists to characterize. +/// +/// This is the convention the rest of the workspace already states and tests -- +/// see `windows-namespace-request-sys` and `windows-file-enumeration-sys`, whose +/// `path` modules define the same pair and assert that the content ceiling is +/// one less than `MAX_PATH`. A probe that measured against a different ceiling +/// than the crates whose designs rest on it would be answering a question nobody +/// asked. +/// +/// Public because the report has to *print* it. A column headed `> MAX` next to +/// a module defining `MAX_PATH` as 260 is read as "over 260", and at a resolved +/// length of exactly 260 that reading is wrong in the one place this probe is +/// supposed to be exact. The renderer states the number instead of naming a +/// constant the reader cannot see. +pub const MAX_PATH_CONTENT: usize = MAX_PATH - 1; + +/// One directory level of the deep tree. Short, so the depth rather than the +/// width is what carries the length, and free of `.` so no segment is itself a +/// relative operator. +const SEGMENT: &str = "aaaaaaaa"; + +/// The file every attempt tries to open. +const TARGET: &str = "target.txt"; + +/// A path shape, and whether it is expected to survive `\\?\` parsing. +/// +/// The three differ only in features the prefix disables, which is what makes +/// the comparison a test of the hypothesis rather than of path length alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shape { + /// Plain backslash-separated segments. Legal under `\\?\` too, so this is + /// the control: it isolates length from parsing. + Plain, + /// Contains `b\..`, which cancels to nothing -- but only if something + /// resolves it. `\\?\` does not. + DotDot, + /// Uses `/` as the separator. Win32 converts it; `\\?\` does not. + ForwardSlash, +} + +impl Shape { + /// A short word for a table. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Plain => "plain", + Self::DotDot => "with `..`", + Self::ForwardSlash => "forward slashes", + } + } + + /// Whether `\\?\` parsing would still resolve this shape. + /// + /// The prediction the hypothesis makes: if the opt-in prefixes internally, + /// the two shapes answering `false` here fail once the path grows past + /// `MAX_PATH`, while `Plain` keeps working. + #[must_use] + pub fn survives_verbatim_parsing(self) -> bool { + matches!(self, Self::Plain) + } +} + +/// What one attempt did. +#[derive(Clone, Debug)] +pub struct Attempt { + /// The shape tried. + pub shape: Shape, + /// Total length the call had to resolve: current directory plus the + /// relative path, **as written**, not the length of the relative part alone + /// and not the length after `..` is collapsed. + /// + /// That distinction is load-bearing for the `..` shape, whose literal is + /// five units longer than its canonical form, so it was **measured** rather + /// than assumed: forcing the deep level to 21 on the development host put + /// plain at 258 and `..` at 263 against a content ceiling of 259, and in a + /// binary with no `longPathAware` manifest plain **opened** while `..` was + /// **refused**. Had Windows collapsed `..` before applying the ceiling, both + /// would have been 258 and both would have opened. So the length Windows + /// compares is the one written, and this is that number. + /// + /// The measurement above is the evidence, and it is self-contained: it uses + /// this crate's own un-manifested binary, so it does not depend on any + /// assumption about some other program's manifest. + /// + /// That independence is the point, because the obvious shortcut is unsound. + /// Reaching for `cmd.exe` to try a long path measures whatever `cmd`'s + /// manifest says, not the un-opted-in case: on the development host -- + /// Windows 11 build 26200, `cmd.exe` 10.0.26100.1 -- `cmd` carries + /// `longPathAware` in its own manifest, beside `dpiAware`, so a path that + /// opens there says nothing about the ceiling. Stated with the build because + /// it is a fact about that binary on that host rather than about `cmd` + /// forever: it has not always been so, and a probe that assumed it either way + /// would be resting on someone else's manifest instead of measuring. + /// + /// **In UTF-16 code units**, which is the unit `MAX_PATH` itself is + /// expressed in. Counting Rust's platform encoding instead would disagree + /// the moment a non-ASCII character appeared in the temporary directory's + /// path, and would put an attempt on the wrong side of the ceiling. + pub resolved_len: usize, + /// Whether that total is too long to fit under the ceiling. + /// + /// That is `> MAX_PATH_CONTENT` (259), **not** `> MAX_PATH` (260): a path of + /// exactly 260 content units does not fit, because the terminator needs the + /// last one. The field name is older than the distinction and is kept for the + /// column it feeds; the comparison is the one the sibling crates make. + pub over_max_path: bool, + /// Whether `CreateFileW` opened the file. + pub opened: bool, + /// The Win32 error when it did not. + pub error: u32, +} + +/// Everything one run observed. +#[derive(Clone, Debug)] +pub struct Observation { + /// Whether this binary declares `longPathAware`. + pub manifest_aware: bool, + /// Whether the machine has `LongPathsEnabled` set to 1. + /// `None` when the run was refused before the registry could be read. + /// + /// A `bool` cannot say "not consulted", and the difference matters: reading + /// an unconsulted flag as `false` made a refused run report `LongPathsEnabled + /// : unset or 0` and "the machine half of the opt-in is absent" on a host + /// where it is set to 1 -- a measured-sounding claim about a query that was + /// never issued. The refusal has to come first, because reading the registry + /// spawns a process and that is one of the calls that hangs, so the honest + /// answer is a third state rather than a default. + pub registry_enabled: Option, + /// Every attempt, short ones first. + pub attempts: Vec, + /// Set when the apparatus itself failed, in which case the attempts say + /// nothing about the machine. + pub apparatus_error: Option, +} + +/// A null-terminated wide string, as Win32 wants. +fn wide(path: &OsStr) -> Vec { + path.encode_wide().chain(std::iter::once(0)).collect() +} + +/// Read `LongPathsEnabled`, which is half the opt-in and is a machine setting +/// rather than anything this process controls. +/// +/// Reported rather than assumed: a run on a machine without it measures the +/// un-opted-in case whatever the manifest says, and reading the answer as +/// though the opt-in were active would invert the conclusion. +#[must_use] +pub fn registry_enabled() -> bool { + // Read through `reg.exe` rather than taking a registry dependency for one + // value in a probe. A missing key, a non-zero exit and an unparsable value + // all mean the same thing here: not enabled. + std::process::Command::new("reg") + .args([ + "query", + r"HKLM\SYSTEM\CurrentControlSet\Control\FileSystem", + "/v", + "LongPathsEnabled", + ]) + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| enabled_in(&String::from_utf8_lossy(&out.stdout))) + .unwrap_or(false) +} + +/// Whether `reg query`'s output says the machine half of the opt-in is on. +/// +/// Separated from the spawn so the reading is testable against captured output +/// rather than against whatever the developing machine happens to be set to. +/// +/// Any nonzero value counts as enabled: this is a boolean flag stored in a +/// DWORD, so the value that is not zero is the one that means yes. +fn enabled_in(stdout: &str) -> bool { + registry_dword(stdout, "LongPathsEnabled").is_some_and(|value| value != 0) +} + +/// The DWORD `reg query ... /v ` printed, if it printed one. +/// +/// Parsed as a whole token rather than searched for as a substring. `reg.exe` +/// prints the value in hex, so a substring test for `0x1` also matches `0x10` +/// and every other value that merely starts that way -- which would report a +/// machine as opted in on the strength of an unrelated setting. +fn registry_dword(stdout: &str, name: &str) -> Option { + stdout.lines().find_map(|line| { + let mut tokens = line.split_whitespace(); + // `reg.exe` prints ` REG_DWORD 0x1`, and this probe queries + // one value, so a line that does not have that shape is not the answer. + if tokens.next()? != name || tokens.next()? != "REG_DWORD" { + return None; + } + let digits = tokens.next()?.strip_prefix("0x")?; + u32::from_str_radix(digits, 16).ok() + }) +} + +/// Create one directory by absolute `\\?\` path, so building the apparatus +/// never depends on the behaviour under test. +fn create_dir_verbatim(path: &Path) -> Result<(), String> { + let verbatim = PathBuf::from(format!(r"\\?\{}", path.display())); + let wide = wide(verbatim.as_os_str()); + // SAFETY: `wide` is a live null-terminated buffer for the duration of the + // call, and a null security descriptor requests the default. + let created = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) }; + if created == 0 { + // SAFETY: called immediately after the failing call. + let error = unsafe { GetLastError() }; + // Already there is success for our purposes: the apparatus is a shape on + // disk, not a thing this run must be the one to have created. + if error != ERROR_ALREADY_EXISTS { + return Err(format!("CreateDirectoryW({verbatim:?}) failed: {error}")); + } + } + Ok(()) +} + +/// Build a directory chain `depth` levels deep under `root`, returning the +/// relative path that reaches the bottom. +fn build_tree(root: &Path, depth: usize) -> Result { + let mut absolute = root.to_path_buf(); + let mut relative = PathBuf::new(); + for _ in 0..depth { + absolute.push(SEGMENT); + relative.push(SEGMENT); + create_dir_verbatim(&absolute)?; + } + Ok(relative) +} + +/// Write the target file at the bottom of the chain, by absolute `\\?\` path. +fn create_target(bottom: &Path) -> Result<(), String> { + let verbatim = PathBuf::from(format!(r"\\?\{}\{TARGET}", bottom.display())); + let wide = wide(verbatim.as_os_str()); + // SAFETY: `wide` is live and null-terminated; the handle is closed below. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + // SAFETY: called immediately after the failing call. + return Err(format!("could not create the target: {}", unsafe { + GetLastError() + })); + } + // SAFETY: `handle` is a live handle this function just opened. + unsafe { CloseHandle(handle) }; + Ok(()) +} + +/// Render one relative path of the requested shape reaching `depth` levels down. +fn relative_path(depth: usize, shape: Shape) -> String { + let mut parts: Vec = (0..depth).map(|_| SEGMENT.to_string()).collect(); + match shape { + Shape::Plain | Shape::ForwardSlash => {} + Shape::DotDot => { + // A descent that immediately cancels. Placed at the bottom so the + // path is at its longest when the operator appears -- the position + // where a prefix-then-parse implementation would be least able to + // resolve it. + parts.push("b".to_string()); + parts.push("..".to_string()); + } + } + parts.push(TARGET.to_string()); + let separator = if shape == Shape::ForwardSlash { + "/" + } else { + r"\" + }; + parts.join(separator) +} + +/// Try to open the target through one relative path, from the current +/// directory, with no prefix of any kind. +fn attempt(current_dir_len: usize, depth: usize, shape: Shape) -> Attempt { + let relative = relative_path(depth, shape); + // Plus one for the separator Windows inserts when it joins the two. Both + // lengths are UTF-16 code units, which is the unit `MAX_PATH` is expressed + // in -- see `current_dir_len`'s construction in `measure`. The relative + // part is built from ASCII constants here, so its two counts agree today; + // it is measured the same way regardless, because a unit that is only + // correct while the input happens to be ASCII is one waiting to be wrong. + let resolved_len = current_dir_len + 1 + Wtf16String::from_os_str(OsStr::new(&relative)).len(); + let wide = wide(OsStr::new(&relative)); + // SAFETY: `wide` is a live null-terminated buffer for the duration of the + // call; the handle, if any, is closed below. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + let opened = handle != INVALID_HANDLE_VALUE; + let error = if opened { + // SAFETY: `handle` is a live handle this call just opened. + unsafe { CloseHandle(handle) }; + 0 + } else { + // SAFETY: called immediately after the failing call. + unsafe { GetLastError() } + }; + Attempt { + shape, + resolved_len, + over_max_path: resolved_len > MAX_PATH_CONTENT, + opened, + error, + } +} + +/// This process's current directory, as a null-terminated wide string. +/// +/// Sized then fetched, which is this API's documented shape: a zero length with +/// a null buffer returns the size *including* the terminator, and the filling +/// call returns the count *excluding* it. +fn current_directory() -> Result, String> { + // SAFETY: the documented sizing form -- a zero length with a null buffer, + // which writes nothing and returns the required size. + let needed = unsafe { GetCurrentDirectoryW(0, std::ptr::null_mut()) }; + if needed == 0 { + // SAFETY: called immediately after the failing call. + return Err(format!("GetCurrentDirectoryW sizing failed: {}", unsafe { + GetLastError() + })); + } + let mut buffer = vec![0_u16; needed as usize]; + // SAFETY: `buffer` has `needed` elements, which is the size the call above + // asked for, and is writable for that length. + let written = unsafe { GetCurrentDirectoryW(needed, buffer.as_mut_ptr()) }; + if written == 0 || written >= needed { + // SAFETY: called immediately after the failing call. + return Err(format!("GetCurrentDirectoryW failed: {}", unsafe { + GetLastError() + })); + } + Ok(buffer) +} + +/// The temporary tree and the process state this experiment borrows. +/// +/// **A guard, because both are leaks if `measure` returns early**, and it +/// returns early on every apparatus failure. This is a library function, so +/// neither is excused by the probe binaries exiting straight afterwards: a test +/// or any other caller keeps running in the process whose current directory was +/// moved. +/// +/// The tree is the sharper of the two. It is deliberately longer than +/// `MAX_PATH`, which is the very property that stops Explorer and `del` from +/// removing it -- so litter left in `%TEMP%` by a probe about long paths is +/// litter that is hard to clear up by hand. +struct Apparatus { + root: PathBuf, + /// Where the process was before [`Self::enter`], if it moved at all. + previous_directory: Option>, +} + +impl Apparatus { + fn new(root: PathBuf) -> Self { + Self { + root, + previous_directory: None, + } + } + + /// Move the process into `directory`, remembering where it was. + fn enter(&mut self, directory: &Path) -> Result<(), String> { + // Captured *before* the move, or there is nothing to go back to. + let previous = current_directory()?; + let wide = wide(directory.as_os_str()); + // SAFETY: `wide` is a live null-terminated buffer for the call. + if unsafe { SetCurrentDirectoryW(wide.as_ptr()) } == 0 { + // SAFETY: called immediately after the failing call. + return Err(format!("SetCurrentDirectoryW failed: {}", unsafe { + GetLastError() + })); + } + self.previous_directory = Some(previous); + Ok(()) + } +} + +impl Drop for Apparatus { + fn drop(&mut self) { + // **Restore the directory first, and that ordering is load-bearing.** + // A process's current directory holds a handle on it, so removing the + // tree while parked inside it fails -- the cleanup would silently do + // nothing and leave exactly the litter this guard exists to prevent. + if let Some(previous) = &self.previous_directory { + // SAFETY: `previous` is the null-terminated buffer + // `GetCurrentDirectoryW` filled, still live here. + unsafe { SetCurrentDirectoryW(previous.as_ptr()) }; + } + + // By verbatim path, for the same reason the tree was built by one: the + // deep branch is past `MAX_PATH`, so an ordinary path would fail to + // reach it on a host that has not opted in -- which is half the hosts + // this probe is meant to run on. + let verbatim = PathBuf::from(format!(r"\\?\{}", self.root.display())); + // Best-effort: a failure here leaves litter, which is worth neither a + // panic in a `Drop` nor a field on an observation about path lengths. + let _ = std::fs::remove_dir_all(&verbatim); + } +} + +/// Run the experiment. +/// +/// `manifest_aware` is what the *caller* knows about its own manifest -- the +/// process cannot ask Windows whether it opted in, so the two binaries pass +/// their own answer and are named for it. +/// +/// The temporary tree and the current directory are both restored before this +/// returns, on every path including the apparatus failures -- see `Apparatus`. +/// +/// # Not safe to call concurrently +/// +/// This borrows **process-wide** state: it moves the current directory, and it +/// builds its tree under a root named after the process id. Two calls at once +/// in one process share both -- one would remove the tree the other was still +/// using, and they would fight over the directory. A unique root per call would +/// not fix that, because there is one current directory per process however the +/// trees are named. +/// +/// The probe binaries call this once and exit, so this costs them nothing. A +/// caller running it from a test suite must serialize its calls; the tests +/// beside this module do exactly that. +#[must_use] +pub fn measure(manifest_aware: bool) -> Observation { + // First, before anything at all: both `temp_dir()` and the `reg.exe` spawn + // below hang on an over-long temporary directory, so neither may run first. + // `registry_enabled` is `None` here rather than `false`: the query was never + // issued, and saying "not enabled" would be a claim about the machine. + if let Some(error) = temp_dir_refusal( + std::env::var_os("TMP").as_deref(), + std::env::var_os("TEMP").as_deref(), + ) { + return Observation { + manifest_aware, + registry_enabled: None, + attempts: Vec::new(), + apparatus_error: Some(error), + }; + } + + let mut observation = Observation { + manifest_aware, + registry_enabled: Some(registry_enabled()), + attempts: Vec::new(), + apparatus_error: None, + }; + + let root = std::env::temp_dir().join(format!("long-path-probe-{}", std::process::id())); + if let Err(error) = create_dir_verbatim(&root) { + observation.apparatus_error = Some(error); + return observation; + } + // From here on the tree exists, so every exit below has something to clean + // up -- including the early returns, which is why this is a guard. + let mut apparatus = Apparatus::new(root.clone()); + + // Deep enough that the resolved path clears `MAX_PATH` with room to spare, + // and shallow enough that the short case stays well under it. + let deep = 40; + let shallow = 1; + + // Only the side effect is wanted. Each attempt spells its own relative path, + // because the spelling is what is under test. + if let Err(error) = build_tree(&root, deep) { + observation.apparatus_error = Some(error); + return observation; + } + // `b`, for the `..` shape to descend into and immediately leave. + for depth in [shallow, deep] { + let mut bottom = root.clone(); + for _ in 0..depth { + bottom.push(SEGMENT); + } + if let Err(error) = create_dir_verbatim(&bottom.join("b")) { + observation.apparatus_error = Some(error); + return observation; + } + if let Err(error) = create_target(&bottom) { + observation.apparatus_error = Some(error); + return observation; + } + } + + // The current directory is the short root for every attempt, so the length + // under test lives in the relative path rather than in the cwd. + if let Err(error) = apparatus.enter(&root) { + observation.apparatus_error = Some(error); + return observation; + } + // **UTF-16 code units, not bytes.** `MAX_PATH` counts what Windows counts, + // and `OsStr::len` counts Rust's platform encoding -- which is WTF-8 here, + // so a non-ASCII character in `%TEMP%` makes the two disagree and can put + // an attempt on the wrong side of the ceiling in the report. `Wtf16String` + // is the workspace's own answer to exactly this: it holds the string in the + // encoding Windows uses, so its `len` is the number under test rather than + // a conversion of one. + let current_dir_len = Wtf16String::from_os_str(root.as_os_str()).len(); + + for depth in [shallow, deep] { + for shape in [Shape::Plain, Shape::DotDot, Shape::ForwardSlash] { + observation + .attempts + .push(attempt(current_dir_len, depth, shape)); + } + } + + observation +} + +/// The longest temporary directory this probe will run in, **in UTF-16 units**. +/// +/// A chosen limit, not a derived one. A long enough temporary directory makes a +/// `longPathAware` process hang rather than fail, and this sits far enough short +/// of that to not care where exactly it starts. +const MAX_TEMP_DIR: usize = 200; + +/// Why this run must refuse to start, if it must, given `%TMP%` and `%TEMP%`. +/// +/// Runs before anything that could resolve a path, because the failure it avoids +/// is a hang: there is nothing to check afterwards when the call never returns. +/// +/// The order mirrors `GetTempPath`: `%TMP%` first, then `%TEMP%`, then fallbacks +/// that are always short. Checking both unconditionally would refuse a run whose +/// `%TMP%` is perfectly usable merely because a stale `%TEMP%` sits beside it. +fn temp_dir_refusal(tmp: Option<&OsStr>, temp: Option<&OsStr>) -> Option { + let (name, value) = match (tmp, temp) { + (Some(value), _) => ("TMP", value), + (None, Some(value)) => ("TEMP", value), + (None, None) => return None, + }; + + // The apparatus is built through `\\?\` paths so that creating it never + // depends on the behaviour under test, and this crate composes that prefix by + // concatenation. Three shapes of temporary directory make that composition + // wrong rather than merely long, and all are refused for the same reason the + // length is: the probe would report an apparatus failure, or measure a path + // that is not the one it names, and either way say nothing about the ceiling. + // + // Checked in this order because each check needs the previous one to have + // passed to be able to say anything true: the prefix tests read the value as + // text, and reading an ill-formed value as text is exactly the substitution + // the second refusal exists to prevent. Classifying first and validating + // afterwards would refuse an ill-formed value under whichever prefix its + // replacement characters happened to spell. + + // `Path::display` substitutes U+FFFD for an unpaired surrogate, so a name + // containing one would compose a verbatim path naming a different file -- + // silently, and in the apparatus rather than in the measurement. + let Some(text) = value.to_str() else { + return Some(format!( + "%{name}% is not well-formed UTF-16. The apparatus composes its `\\\\?\\` \ + paths as text, which would replace the ill-formed part and name a \ + different file. Point %{name}% somewhere expressible." + )); + }; + + // `\\?\` and `\\.\` open with two backslashes but are the device namespace, + // not UNC, so they are separated out ahead of the UNC test rather than + // reported as a server share the machine does not have. The refusal is not + // only about the doubled prefix: `\\?\` turns off the path normalisation that + // this probe exists to measure, so a run rooted there would measure the + // verbatim path's ceiling and label it the ordinary one. + if let Some(prefix) = [r"\\?\", r"\\.\"] + .into_iter() + .find(|prefix| text.starts_with(prefix)) + { + return Some(format!( + "%{name}% starts with `{prefix}`, which names the device namespace rather \ + than an ordinary directory. This probe composes its own `\\\\?\\` prefix by \ + concatenation, and `\\\\?\\` additionally turns off the path normalisation \ + the probe measures. Point %{name}% at an ordinary local directory." + )); + } + + // A UNC root needs `\\?\UNC\server\share`, not `\\?\` glued to `\\server`. + // Supporting it properly is not the problem -- it is three lines -- but it + // could not be exercised on any machine this workspace is developed or tested + // on, and an untested path through the apparatus is worth less than an honest + // refusal. + if text.starts_with(r"\\") { + return Some(format!( + "%{name}% is a UNC path. This probe builds its apparatus through `\\\\?\\` \ + paths, which spell a UNC root differently, and it does not implement that \ + spelling. Point %{name}% at a local directory." + )); + } + + // UTF-16 units, the unit Windows counts, for the reason `resolved_len` + // records: a non-ASCII character makes a byte count disagree. + let units = Wtf16String::from_os_str(value).len(); + if units <= MAX_TEMP_DIR { + return None; + } + + Some(format!( + "%{name}% is {units} UTF-16 units, over this probe's limit of {MAX_TEMP_DIR}. \ + A temporary directory that long makes a longPathAware process hang rather \ + than fail, so the run is refused instead. Point %{name}% somewhere shorter." + )) +} + +/// Whether an error means "the path was rejected for length", as opposed to a +/// genuine absence. +/// +/// Windows reports an over-long path as `ERROR_PATH_NOT_FOUND` rather than +/// anything length-specific, which is why the apparatus creates every target +/// first: a `NOT_FOUND` from a file that provably exists is the length refusal. +#[must_use] +pub fn is_refusal(attempt: &Attempt) -> bool { + !attempt.opened && matches!(attempt.error, ERROR_PATH_NOT_FOUND | ERROR_FILE_NOT_FOUND) +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-platform-probes/src/long_path/tests.rs b/crates/windows-platform-probes/src/long_path/tests.rs new file mode 100644 index 00000000..876008ed --- /dev/null +++ b/crates/windows-platform-probes/src/long_path/tests.rs @@ -0,0 +1,363 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`measure`](super::measure)'s apparatus. +//! +//! **These assert what the experiment gives back, not what it found.** What it +//! finds is a fact about the host -- whether the manifest and the registry +//! setting lift `MAX_PATH` -- and asserting that here would encode this +//! machine's configuration into the suite. What must hold on every host is that +//! running the experiment leaves the process and the disk as it found them. +//! +//! # Why these serialize +//! +//! `measure` borrows two pieces of **process-wide** state: the current +//! directory, and a temporary root named after the process id. This crate's +//! tests run as threads in one process, so two of these running at once would +//! share both -- one call removing the tree another was still using, and the +//! two fighting over the current directory. +//! +//! That is a property of `measure` rather than a defect in it, and the fix is +//! not a per-call unique root: the current directory is one per process however +//! the directories are named, so concurrent calls could not work whatever the +//! tree was called. See `measure`'s own documentation. These tests therefore +//! take a lock, which is also what a consumer would have to do. +//! +//! Found by writing the third test below, which failed until the lock existed. + +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt as _; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; + +use wtf_string::Wtf16String; + +use super::measure; + +/// Serializes the tests here, for the reason in this module's documentation. +static APPARATUS: Mutex<()> = Mutex::new(()); + +/// Take the lock, ignoring poisoning. +/// +/// A panic in one of these tests leaves the mutex poisoned, which would turn +/// one real failure into three and hide which was the original. +fn exclusive() -> MutexGuard<'static, ()> { + APPARATUS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Where this process's current directory points. +fn current_directory() -> PathBuf { + std::env::current_dir().expect("the test process must have a current directory") +} + +/// The tree `measure` builds, named as it names it. +fn apparatus_root() -> PathBuf { + std::env::temp_dir().join(format!("long-path-probe-{}", std::process::id())) +} + +#[test] +fn measuring_leaves_the_current_directory_where_it_found_it() { + // **The leak that matters most in a library.** `measure` moves the process + // into its temporary root so the length under test lives in the relative + // path rather than in the current directory. The process is shared, so + // failing to move back would silently re-root every later relative path in + // whatever called this -- a test, or any other consumer. + let _lock = exclusive(); + let before = current_directory(); + + let _ = measure(false); + + assert_eq!( + current_directory(), + before, + "the experiment left the process parked somewhere else" + ); +} + +#[test] +fn measuring_removes_the_tree_it_built() { + // The tree is deliberately deeper than `MAX_PATH`, which is exactly what + // stops Explorer and `del` from clearing it up -- so litter from a probe + // about long paths is litter that is awkward to remove by hand. + let _lock = exclusive(); + + let _ = measure(false); + + assert!( + !apparatus_root().exists(), + "the experiment left its temporary tree behind at {}", + apparatus_root().display() + ); +} + +#[test] +fn the_apparatus_is_cleaned_up_even_when_the_experiment_is_run_twice() { + // Two calls in one process, which is the shape a test run takes and the + // shape the probe binaries never do. + // + // A leak from the first run does *not* surface as an apparatus error, and it + // is worth saying so: `create_dir_verbatim` treats `ERROR_ALREADY_EXISTS` as + // success and `create_target` opens with `CREATE_ALWAYS`, so a surviving tree + // is silently reused and the second run reports itself perfectly healthy. The + // assertion that does the work here is the `exists()` check below -- the + // second `apparatus_error` is a control on the run itself, not the leak + // detector. + let _lock = exclusive(); + let before = current_directory(); + + let first = measure(false); + let second = measure(false); + + assert_eq!(first.apparatus_error, None, "first run"); + assert_eq!(second.apparatus_error, None, "second run"); + assert!( + !apparatus_root().exists(), + "{} survived", + apparatus_root().display() + ); + assert_eq!(current_directory(), before); +} + +#[test] +fn the_resolved_length_is_counted_in_the_unit_max_path_uses() { + // **The bug this guards is invisible on an ASCII host**, which is every + // machine this has run on so far. `MAX_PATH` counts UTF-16 code units; + // `OsStr::len` counts Rust's platform encoding, which is WTF-8 here. The + // two agree for ASCII and diverge for anything else, so a `%TEMP%` with a + // non-ASCII character made the reported length too large and could push an + // attempt onto the wrong side of the ceiling in the report. + // + // Asserted on the encoding rather than through `measure`, because the fault + // needs a non-ASCII temporary directory that this test cannot conjure -- + // and pinning the relationship is what actually stops the regression. + let ascii = OsString::from("C:\\Temp"); + assert_eq!( + Wtf16String::from_os_str(&ascii).len(), + ascii.len(), + "the two units must agree for ASCII, or this test proves nothing below" + ); + + // U+00E9 is one UTF-16 unit and two WTF-8 bytes; U+4E2D is one and three. + // A path Windows sees as shorter than `MAX_PATH` can therefore look longer + // when measured in bytes -- the direction that matters, since it would + // report a refusal as expected when it was not. + for accented in ["C:\\Tempé", "C:\\Temp中"] { + let path = OsString::from(accented); + let wide = Wtf16String::from_os_str(&path).len(); + + assert!( + wide < path.len(), + "{accented:?} must expose the divergence: {wide} wide vs {} bytes", + path.len() + ); + assert_eq!( + wide, + accented.chars().count(), + "every character here is one UTF-16 unit, so the wide count is the character count" + ); + } +} + +#[test] +fn an_over_long_tmp_is_refused_before_the_call_that_would_hang() { + // The limit is a flat number, so the test is just that it is applied where it + // says: at the limit run, one past it refuse. + let ok = OsString::from("c".repeat(super::MAX_TEMP_DIR)); + assert!( + super::temp_dir_refusal(Some(&ok), None).is_none(), + "the limit itself must run" + ); + + let bad = OsString::from("c".repeat(super::MAX_TEMP_DIR + 1)); + let refusal = super::temp_dir_refusal(Some(&bad), None).expect("one past the limit refuses"); + assert!( + refusal.contains("%TMP%") && refusal.contains(&(super::MAX_TEMP_DIR + 1).to_string()), + "the refusal must name the variable and its length, or it cannot be acted on: {refusal}" + ); +} + +#[test] +fn temp_is_only_consulted_when_tmp_is_unset() { + // `GetTempPath` reads `%TMP%` first, so a short `%TMP%` decides the run and a + // long `%TEMP%` beside it is never looked at. Refusing on it would turn a + // perfectly usable configuration into a false apparatus error. + let short = OsString::from("c".repeat(20)); + let long = OsString::from("c".repeat(super::MAX_TEMP_DIR + 1)); + + assert!( + super::temp_dir_refusal(Some(&short), Some(&long)).is_none(), + "a usable %TMP% wins, whatever %TEMP% says" + ); + assert!( + super::temp_dir_refusal(None, Some(&long)).is_some(), + "with %TMP% unset, %TEMP% is what GetTempPath uses and what can hang" + ); + assert!( + super::temp_dir_refusal(None, None).is_none(), + "with neither set the fallbacks are short and the call returns" + ); +} + +#[test] +fn the_refusal_length_is_counted_in_utf16_units() { + // Counting Rust's platform encoding instead would disagree the moment a + // non-ASCII character appeared, and disagreeing here means either a false + // refusal or a real hang. + // + // The fixture is chosen so every reading gives a different answer, and only + // one of them is right: 200 characters outside the BMP are 200 chars (which + // would be allowed, since the limit is 200), 400 UTF-16 units (refused, and + // correct), and 800 bytes of WTF-8. + let astral = OsString::from("\u{1F600}".repeat(super::MAX_TEMP_DIR)); + assert_eq!( + Wtf16String::from_os_str(&astral).len(), + 400, + "the fixture must be over the limit in UTF-16 units specifically" + ); + + let refusal = + super::temp_dir_refusal(Some(&astral), None).expect("400 UTF-16 units is over the limit"); + assert!( + refusal.contains("400"), + "the length reported must be the one Windows counts: {refusal}" + ); +} + +#[test] +fn a_unc_temp_dir_is_refused_rather_than_spelled_wrong() { + // The apparatus composes `\\?\` by concatenation, and a UNC root needs + // `\\?\UNC\server\share` rather than `\\?\` glued onto `\\server`. Gluing + // produces a malformed path, so the run would fail in the apparatus and + // report nothing about the ceiling. + let unc = OsString::from(r"\\server\share\temp"); + let refusal = super::temp_dir_refusal(Some(&unc), None).expect("a UNC root is refused"); + + assert!( + refusal.contains("UNC") && refusal.contains("%TMP%"), + "the refusal must name the shape and the variable: {refusal}" + ); + + // The control: an ordinary local path with the same prefix character must + // still run. `\` alone is not UNC, and refusing it would reject a rooted + // path on the current drive. + let rooted = OsString::from(r"\temp"); + assert!( + super::temp_dir_refusal(Some(&rooted), None).is_none(), + "a single leading separator is a rooted local path, not a UNC root" + ); +} + +#[test] +fn a_temp_dir_that_is_not_well_formed_utf16_is_refused() { + // `Path::display` substitutes U+FFFD for an unpaired surrogate, so composing + // the apparatus's verbatim paths as text would name a different file -- + // silently, and in the apparatus rather than in the measurement. + // + // 0xD800 is a lone high surrogate: valid in a Windows path, not expressible + // as a Rust `str`. + let ill_formed = OsString::from_wide(&[0x0043, 0x003A, 0x005C, 0xD800]); + assert!( + ill_formed.to_str().is_none(), + "the fixture must actually be ill-formed, or the test proves nothing" + ); + + let refusal = + super::temp_dir_refusal(Some(&ill_formed), None).expect("an ill-formed path is refused"); + assert!( + refusal.contains("well-formed") && refusal.contains("%TMP%"), + "the refusal must say what is wrong with it: {refusal}" + ); +} + +#[test] +fn a_device_namespace_temp_dir_is_refused_as_itself_and_not_as_unc() { + // `\\?\` and `\\.\` open with the two backslashes a UNC root does, so a + // prefix test written for UNC claims them too. The refusal would then tell + // someone with a perfectly local temporary directory that it is a server + // share, which is the defect this whole probe is written against: a report + // asserting something the run never established. + for prefix in [r"\\?\", r"\\.\"] { + let value = OsString::from(format!(r"{prefix}C:\Temp")); + let refusal = super::temp_dir_refusal(Some(&value), None) + .unwrap_or_else(|| panic!("{prefix} is refused")); + + assert!( + refusal.contains(prefix), + "the refusal must name the prefix it actually found: {refusal}" + ); + assert!( + !refusal.contains("UNC"), + "a device-namespace path is not a UNC root and must not be called one: {refusal}" + ); + } +} + +#[test] +fn an_ill_formed_temp_dir_is_classified_before_its_prefix_is_read() { + // Ordering, pinned: the prefix tests read the value as text, and reading an + // ill-formed value as text is the exact substitution the well-formedness + // refusal exists to prevent. Classifying first would report this value under + // whichever prefix its replacement characters happened to spell -- here, + // UNC -- and the explanation would be about the wrong thing. + let ill_formed = OsString::from_wide(&[0x005C, 0x005C, 0x0073, 0xD800]); + assert!( + ill_formed.to_str().is_none(), + "the fixture must actually be ill-formed, or the test proves nothing" + ); + + let refusal = super::temp_dir_refusal(Some(&ill_formed), None) + .expect("an ill-formed path is refused whatever it starts with"); + assert!( + refusal.contains("well-formed"), + "the refusal must be about the encoding, not the prefix: {refusal}" + ); + assert!( + !refusal.contains("UNC"), + "an ill-formed value must not be classified by text it cannot be read as: {refusal}" + ); +} + +/// What `reg query HKLM\...\FileSystem /v LongPathsEnabled` prints, verbatim +/// apart from the value, which is the part under test. +fn reg_output(value: &str) -> String { + format!( + "\r\n\ + HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\r\n\ + LongPathsEnabled REG_DWORD {value}\r\n\r\n" + ) +} + +#[test] +fn the_registry_value_is_read_as_a_number_and_not_as_a_substring() { + // The reading this replaces tested the output for the substring `0x1`. The + // failure that costs something is the false negative: every nonzero value + // that does not happen to begin with a 1 read as disabled, so a machine set + // to `0x2` would be reported as not opted in and the run's conclusion would + // invert. `0x2` is therefore the case that matters here. + assert!( + super::enabled_in(®_output("0x2")), + "any nonzero value is the flag set, not just the ones spelled with a 1" + ); + assert!(super::enabled_in(®_output("0x1")), "0x1 is enabled"); + assert!(!super::enabled_in(®_output("0x0")), "0x0 is disabled"); +} + +#[test] +fn a_registry_answer_that_is_not_the_queried_value_reads_as_disabled() { + // Absent, wrong type, and unparsable all mean the same thing here, and it is + // the same thing a missing key means: not opted in. The point of parsing the + // whole line is that a value belonging to something else can no longer be + // mistaken for this one. + for stdout in [ + String::new(), + " SomethingElse REG_DWORD 0x1\r\n".to_string(), + " LongPathsEnabled REG_SZ 0x1\r\n".to_string(), + " LongPathsEnabled REG_DWORD enabled\r\n".to_string(), + " LongPathsEnabled REG_DWORD\r\n".to_string(), + ] { + assert!( + !super::enabled_in(&stdout), + "only the queried DWORD may report the machine as opted in: {stdout:?}" + ); + } +} diff --git a/crates/windows-platform-probes/src/long_path_report.rs b/crates/windows-platform-probes/src/long_path_report.rs new file mode 100644 index 00000000..614fec75 --- /dev/null +++ b/crates/windows-platform-probes/src/long_path_report.rs @@ -0,0 +1,419 @@ +// Copyright (c) Mike Grier. + +//! Renders one long-path run. Shared by the two binaries, which differ only in +//! whether their manifest declares `longPathAware`. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. See this crate's DESIGN-NOTES.md. + +use std::fmt::Write as _; + +use crate::long_path::{MAX_PATH_CONTENT, Observation, Shape, is_refusal}; + +#[cfg(test)] +mod tests; + +/// The probe's whole report, composed into `out`. +/// +/// Takes the flag rather than a finished [`Observation`], and runs the +/// measurement itself, so that this is the crate's ordinary renderer shape: +/// `main` is one [`crate::report::emit_report`] call and everything the probe +/// knows is composed *inside* it. +/// +/// The difference is not cosmetic. Passing an `&Observation` would mean the +/// measurement ran while evaluating the argument -- before this function is +/// entered, with `out` still empty -- so a panic anywhere in it would print +/// nothing at all, not even the banner that every captured report is supposed to +/// carry. Composing the banner and the header first makes the buffer worth +/// emitting from the moment measurement begins. +pub fn render(out: &mut String, manifest_aware: bool) { + preamble(out); + // Everything above is already in `out`, so the report survives whatever this + // does. + body(out, &crate::long_path::measure(manifest_aware)); +} + +/// Everything that can be written before the measurement runs. +/// +/// Split out so [`body`] can be tested. The ordering is the point rather than an +/// artefact: this must reach `out` before [`crate::long_path::measure`] is +/// called, or a panic inside the measurement prints nothing at all. +fn preamble(out: &mut String) { + // First line of the report, and part of the composed text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); + let _ = writeln!( + out, + "== does the long-path opt-in lift MAX_PATH for a relative path? ==\n" + ); +} + +/// Everything the report says once the measurement is in hand. +/// +/// Takes a finished [`Observation`] so it is a pure function of data, which is +/// the whole reason it exists apart from [`render`]: `render` cannot be called +/// without building a directory tree and moving the process's current directory, +/// so anything left inside it is untestable. What lives here is not formatting -- +/// it is the length-refusal gating, the ceiling the column compares against, the +/// registry warning, and the apparatus-error early return, two of which are +/// fixes for defects a review round had to find by reading output. +fn body(out: &mut String, observation: &Observation) { + let _ = writeln!( + out, + "manifest longPathAware : {}", + if observation.manifest_aware { + "yes" + } else { + "no" + } + ); + let _ = writeln!( + out, + "LongPathsEnabled : {}", + match observation.registry_enabled { + Some(true) => "1", + Some(false) => "unset or 0", + None => "not consulted (the run was refused first)", + } + ); + + // Only when it was read and was off. `None` means the query never happened, + // and warning that "the machine half of the opt-in is absent" on that basis + // is a claim about the machine drawn from a measurement that was not taken -- + // observed reporting `unset or 0` on a host whose value is 1. + if observation.registry_enabled == Some(false) { + let _ = writeln!( + out, + "\n*** The machine half of the opt-in is absent, so this run measures the\n\ + *** un-opted-in case whatever the manifest says. The rows below are still\n\ + *** real, but they answer a different question than the one intended." + ); + } + + if let Some(error) = &observation.apparatus_error { + let _ = writeln!( + out, + "\n*** APPARATUS FAILED: {error}\n\ + *** Nothing below says anything about the machine." + ); + return; + } + + // The ceiling is printed rather than named. `> MAX` beside a module that + // defines `MAX_PATH` as 260 reads as "over 260", and the number that actually + // governs is 259 -- the terminator takes the last unit. Those differ at + // exactly one length, which is exactly the length this probe exists to be + // right about. + let _ = writeln!( + out, + "\n{:<18} {:>8} {:>8} {:<10} error", + "shape", + "resolved", + format!("> {MAX_PATH_CONTENT}"), + "result" + ); + for attempt in &observation.attempts { + let _ = writeln!( + out, + "{:<18} {:>8} {:>8} {:<10} {}", + attempt.shape.label(), + attempt.resolved_len, + if attempt.over_max_path { "yes" } else { "no" }, + if attempt.opened { "opened" } else { "REFUSED" }, + if attempt.opened { + String::new() + } else { + // `is_refusal` distinguishes a length rejection from a genuine + // absence, and says nothing about length itself -- so it is only + // the length refusal when the row is *also* over the ceiling. + // Below it, the same not-found means the apparatus is wrong, and + // labelling that "the length refusal" would blame the ceiling for + // a file that was simply missing. + // + // Over the ceiling it is only *unambiguously* the length refusal + // for a shape that survives verbatim parsing. For the other two it + // is exactly the ambiguity this probe exists to resolve: if the + // system prepends `\\?\` past the ceiling, `..` and `/` stop being + // resolved, and the path as written then names something that was + // never created -- a genuine absence, and the sharp edge itself. + // Calling that "the length refusal" would file the finding as its + // own control. + format!( + "{}{}", + attempt.error, + match ( + attempt.over_max_path, + is_refusal(attempt), + attempt.shape.survives_verbatim_parsing() + ) { + (true, true, true) => + " (not-found; this shape survives `\\\\?\\` parsing and the target \ + exists, so this is the length refusal)", + (true, true, false) => + " (not-found; either the length refusal or this shape ceasing to \ + resolve past the ceiling -- the verdict below decides which)", + (false, true, _) => + " (not-found BELOW the ceiling; the apparatus is wrong, not the path length)", + _ => "", + } + ) + } + ); + } + + let _ = writeln!(out, "\n{}", verdict(observation)); +} + +/// What the rows mean, stated rather than left for the reader to infer. +fn verdict(observation: &Observation) -> String { + let long: Vec<_> = observation + .attempts + .iter() + .filter(|attempt| attempt.over_max_path) + .collect(); + if long.is_empty() { + return "-- no attempt exceeded MAX_PATH, so this run tested nothing.".to_string(); + } + + // The control, and it is not optional. Every verdict below is a claim about a + // *change* at the ceiling -- "stopped resolving past it while working below + // it" -- and a change needs both sides. Reading only the long attempts would + // let a shape that never worked at any length be reported as the sharp edge, + // which is this probe's headline finding and the opposite of what happened. + // + // Two different situations, and they must not share a sentence. + // + // A shape with **no** below-ceiling attempt was never tried there. The shallow + // depth is meant to land under the ceiling for every shape, and the shapes do + // not cross together -- `..` carries an extra `\b\..`, so it reaches the + // ceiling five units sooner than plain. A current directory long enough to + // separate them puts `..` over while plain is still under. + // + // A shape that *was* tried below the ceiling and did not open is the opposite: + // the apparatus is broken. + // + // Collapsing the two announced "an apparatus or platform problem" on runs + // where every attempt opened and nothing failed at all. Measured with a + // 215-character `%TEMP%`, where all six attempts opened and the report still + // named a problem -- the report asserting a cause it did not establish, which + // is the one thing this verdict exists to avoid. + // + // That measurement predates the limit on `%TMP%`/`%TEMP%` length, which now + // refuses a temporary directory long enough to reach either state. Both are + // kept because a change to `shallow` or `SEGMENT` would put them back, and + // nothing else would notice. + // + // Paired on `over_max_path` rather than on depth, since it is the ceiling + // rather than the depth that decides whether an attempt is a control. + // Whether anything *failed* is a separate question, answered below: with some + // attempts under the ceiling the run can say the apparatus is sound, and with + // none it cannot say anything about the apparatus at all. + let tried_below = |shape: Shape| { + observation + .attempts + .iter() + .any(|attempt| attempt.shape == shape && !attempt.over_max_path) + }; + let opened_below = |shape: Shape| { + observation + .attempts + .iter() + .any(|attempt| attempt.shape == shape && !attempt.over_max_path && attempt.opened) + }; + let shapes = [Shape::Plain, Shape::DotDot, Shape::ForwardSlash]; + + let broken: Vec<&str> = shapes + .into_iter() + .filter(|shape| tried_below(*shape) && !opened_below(*shape)) + .map(Shape::label) + .collect(); + if !broken.is_empty() { + return format!( + "-- NO VERDICT. These shapes were tried below the ceiling and did not open:\n \ + {}. The apparatus is wrong, so nothing here is a finding about\n \ + the ceiling.", + broken.join(", ") + ); + } + + let untried: Vec<&str> = shapes + .into_iter() + .filter(|shape| !tried_below(*shape)) + .map(Shape::label) + .collect(); + if !untried.is_empty() { + // Whether *anything* landed below the ceiling, which decides whether this + // report can say a word about the apparatus. + // + // "Every attempt that did land below the ceiling opened" is true when some + // did, and vacuously true when none did -- and a vacuous truth offered as + // affirmative evidence is the same defect this whole cascade exists to + // prevent. Measured with a 226-character `%TEMP%`: all six attempts were + // over the ceiling and every one was REFUSED, and the report told the + // reader that everything below the ceiling had opened. The rows directly + // above it said otherwise. That length is now refused outright, so this + // guard defends against a change to `shallow` or `SEGMENT` rather than + // against a host. + let anything_below = observation + .attempts + .iter() + .any(|attempt| !attempt.over_max_path); + + if !anything_below { + return format!( + "-- NO VERDICT. No attempt landed below {MAX_PATH_CONTENT} on this run, so there is no\n \ + baseline to read the long case against for any shape: {}.\n \ + This says nothing about the apparatus either way -- it was never\n \ + exercised under the ceiling. The shallow depth is meant to land\n \ + there, so either it or the segment length has been changed.", + untried.join(", ") + ); + } + + return format!( + "-- NO VERDICT. These shapes were never tried *below* {MAX_PATH_CONTENT} on this run,\n \ + so there is no baseline to read the long case against: {}.\n \ + Every attempt that did land below the ceiling opened, so this is not an\n \ + apparatus fault: the shallow depth is meant to land under the ceiling\n \ + for every shape, and for these it did not.", + untried.join(", ") + ); + } + + // The reference shape must have been tried *above* the ceiling too, or there + // is nothing to conclude about lifting. Reading "no long plain attempt + // opened" as "plain was refused" conflates a refusal with an attempt that was + // never made. + // + // Measured, not hypothesised -- but reached by changing a constant, because + // no host configuration can produce it. This state needs the *deep* plain + // attempt to be under the ceiling, and the deep relative path is a fixed 370 + // units (40 levels of an 8-unit `SEGMENT`, separators, `target.txt`), so it + // resolves to at least ~392 whatever the temporary directory costs. + // + // Forced by setting the deep level to 21 on the development host, the run + // produced plain at 258 opened and `..` at 263 refused -- and the verdict + // announced "even the plain shape was refused past the ceiling", which the + // run had not observed and which the table directly contradicted. + // + // The five-unit straddle that `..` creates belongs to the guard above, not + // this one; repeating it here would point the next reader at a host setting + // that cannot reach this branch. + let plain_long: Vec<_> = long + .iter() + .filter(|attempt| attempt.shape == Shape::Plain) + .collect(); + if plain_long.is_empty() { + return "-- NO VERDICT. No plain-shape attempt exceeded MAX_PATH, so the reference\n \ + shape was never tested against the ceiling on this run and there is\n \ + nothing to say about lifting." + .to_string(); + } + + // *Why* a long attempt failed, which neither guard above constrains. Both + // verdicts below are claims about the ceiling, and "did not open" is not + // evidence about the ceiling: a sharing violation, an access denial, or an + // antivirus hold on the target fails the open without the length being + // involved at all. `apparatus_error` does not catch it either, because a + // failed attempt is a result rather than a broken apparatus. + // + // The crate already owns the discriminator -- `is_refusal` exists to separate + // a length rejection from a genuine absence -- and it was being spent on a + // parenthetical in the table while the verdict that carries the finding + // ignored it. + let unexplained: Vec = long + .iter() + .filter(|attempt| !attempt.opened && !is_refusal(attempt)) + .map(|attempt| format!("{} (error {})", attempt.shape.label(), attempt.error)) + .collect(); + if !unexplained.is_empty() { + return format!( + "-- NO VERDICT. These attempts past the ceiling failed for something that is\n \ + not a length refusal, so nothing here bears on MAX_PATH: {}.", + unexplained.join(", ") + ); + } + + // Sound only because of the three checks above: every shape opened below the + // ceiling, plain was actually tried above it, and every long failure was a + // length refusal rather than some other error. + let plain_long_opened = plain_long.iter().any(|attempt| attempt.opened); + let reparsing: Vec<&str> = long + .iter() + .filter(|attempt| !attempt.shape.survives_verbatim_parsing() && !attempt.opened) + .map(|attempt| attempt.shape.label()) + .collect(); + + // Whether the opt-in was actually in effect, which is the one input every + // guard above ignores and the only one that decides what a refusal *means*. + // + // Both halves have to hold. With neither, or with only one, a refusal past + // the ceiling is the documented outcome rather than a counter-example to it: + // `MAX_PATH` applying to a process that has not opted in is what `MAX_PATH` + // is. Reading it as "the documented reading is wrong" was this report's + // flagship line in `probe-long-path-unaware` on every correctly configured + // host -- announcing that Microsoft's documentation is refuted by the run + // that most exactly confirms it, and doing so from the half of the pair whose + // entire job is to be the baseline the other half is read against. + // `None` for the registry cannot reach here -- a refused run returns from + // `body` at the apparatus-error block above -- but it is spelled out rather + // than lumped in with `Some(false)`, because "not consulted" is not "off" and + // conflating them is the defect this third state was introduced to remove. + let missing = match (observation.manifest_aware, observation.registry_enabled) { + (false, Some(false)) => Some("neither half of the opt-in was in effect"), + (false, Some(true)) => Some("this binary carries no `longPathAware` manifest"), + (true, Some(false)) => Some("the machine's `LongPathsEnabled` is unset or 0"), + (_, None) => Some("the machine's `LongPathsEnabled` was never read"), + (true, Some(true)) => None, + }; + + // Both directions have to consult it, not just the refusal. "Lifted" credited + // to an opt-in that was not in effect would be the same error facing the other + // way, and a ceiling that lifts without opting in is a far stranger result + // than either verdict below describes. + if let Some(missing) = missing { + if plain_long_opened { + return format!( + "-- UNEXPECTED, and not a verdict on the opt-in. A relative path resolved\n \ + past the ceiling while the opt-in was NOT in effect:\n \ + {missing}.\n \ + Nothing here credits the opt-in with that, because it was not on.\n \ + Establish why this host lifts the ceiling unopted before reading any\n \ + comparison against it." + ); + } + return format!( + "-- BASELINE, not a finding. `MAX_PATH` applied to a relative path past the\n \ + ceiling, which is what it does when the opt-in is not in effect:\n \ + {missing}.\n \ + This is the case the opted-in half is read against; it neither supports\n \ + nor contradicts the documented reading." + ); + } + + if !plain_long_opened { + return "-- MAX_PATH was NOT lifted for a relative path: the opt-in was in effect --\n \ + manifest and registry both -- and even the plain shape was refused past\n \ + the ceiling. The documented reading is wrong for this configuration." + .to_string(); + } + if reparsing.is_empty() { + "-- MAX_PATH was lifted for a relative path, and the path was still parsed\n\ + normally: `..` and forward slashes resolved past the ceiling exactly as\n\ + they do below it. No evidence of a prefix-then-parse implementation." + .to_string() + } else { + format!( + "-- SHARP EDGE. Length was lifted, but these shapes stopped resolving past\n\ + the ceiling while working below it: {}.\n\ + That is the signature of regularize-then-prefix: `\\\\?\\` disables exactly\n\ + these features, so a relative path changes meaning at MAX_PATH.", + reparsing.join(", ") + ) + } +} diff --git a/crates/windows-platform-probes/src/long_path_report/tests.rs b/crates/windows-platform-probes/src/long_path_report/tests.rs new file mode 100644 index 00000000..e3e45f73 --- /dev/null +++ b/crates/windows-platform-probes/src/long_path_report/tests.rs @@ -0,0 +1,656 @@ +// Copyright (c) Mike Grier. + +//! Tests for the verdict. +//! +//! Every defect these pin was real, and every one reached a review round rather +//! than a build: the verdict turns data into English, and English can be false in +//! ways neither the compiler nor a passing test suite could see. Round after +//! round found them by reading output -- stated without a count, because the +//! count moved every time and a stale one here was itself a finding. This file +//! is the attempt to stop finding them that way. +//! +//! So each test is named for the wrong sentence it forbids, and asserts **both** +//! halves: that the right claim is made, and that the wrong one is not. Asserting +//! only the former would let a verdict pass by saying everything at once. +//! +//! `verdict` is a pure function of an [`Observation`], which is why this costs +//! nothing to test -- no filesystem, no Win32, no privileges, no current +//! directory to restore. That the probe's own apparatus is expensive to exercise +//! is not a reason for its conclusions to be untested. + +use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_PATH_NOT_FOUND}; + +use super::verdict; +use crate::long_path::{Attempt, Observation, Shape}; + +const SHAPES: [Shape; 3] = [Shape::Plain, Shape::DotDot, Shape::ForwardSlash]; + +/// An attempt that landed under the ceiling. +fn below(shape: Shape, opened: bool) -> Attempt { + Attempt { + shape, + resolved_len: 78, + over_max_path: false, + opened, + error: if opened { 0 } else { ERROR_PATH_NOT_FOUND }, + } +} + +/// An attempt that landed over the ceiling. +fn above(shape: Shape, opened: bool, error: u32) -> Attempt { + Attempt { + shape, + resolved_len: 429, + over_max_path: true, + opened, + error: if opened { 0 } else { error }, + } +} + +/// The three below-ceiling attempts of a healthy run: every shape opened. +fn healthy_below() -> Vec { + SHAPES.into_iter().map(|shape| below(shape, true)).collect() +} + +/// The three above-ceiling attempts, all with the same outcome. +fn all_above(opened: bool, error: u32) -> Vec { + SHAPES + .into_iter() + .map(|shape| above(shape, opened, error)) + .collect() +} + +fn observation( + manifest_aware: bool, + registry_enabled: bool, + attempts: Vec, +) -> Observation { + Observation { + manifest_aware, + registry_enabled: Some(registry_enabled), + attempts, + apparatus_error: None, + } +} + +/// A run in which everything is in place and the ceiling genuinely lifted. +fn opted_in_and_lifted() -> Observation { + let mut attempts = healthy_below(); + attempts.extend(all_above(true, 0)); + observation(true, true, attempts) +} + +#[test] +fn a_refusal_without_the_opt_in_is_not_evidence_against_the_documentation() { + // The flagship line of `probe-long-path-unaware` on every correctly + // configured host, and it said "The documented reading is wrong for this + // configuration". The documented reading is about the *opt-in*; a binary with + // no manifest never opted in, so `MAX_PATH` applying to it is what `MAX_PATH` + // means. The run that most exactly confirms the documentation was reporting + // that it refutes it. + let mut attempts = healthy_below(); + attempts.extend(all_above(false, ERROR_PATH_NOT_FOUND)); + let text = verdict(&observation(false, true, attempts)); + + assert!( + text.contains("BASELINE"), + "an un-opted-in refusal is the baseline the opted-in half is read against: {text}" + ); + assert!( + !text.contains("documented reading is wrong"), + "nothing here bears on the documentation, which describes the opted-in case: {text}" + ); + assert!( + text.contains("longPathAware"), + "the report must name which half was missing, or the reader cannot tell: {text}" + ); +} + +#[test] +fn a_refusal_with_the_opt_in_in_effect_is_evidence_against_the_documentation() { + // The control for the test above. Without it, deleting the whole conclusion + // would leave that one green -- it asserts an absence, and an absence is + // satisfied by saying nothing at all. + let mut attempts = healthy_below(); + attempts.extend(all_above(false, ERROR_PATH_NOT_FOUND)); + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("documented reading is wrong"), + "with both halves in effect a refusal *is* the counter-example: {text}" + ); + assert!( + !text.contains("BASELINE"), + "this run opted in, so it is not the baseline: {text}" + ); +} + +#[test] +fn a_ceiling_that_lifts_without_the_opt_in_is_not_credited_to_the_opt_in() { + // The same error facing the other way. A host that lifts the ceiling for a + // binary that never opted in is a stranger result than either headline + // verdict describes, and reporting it as "MAX_PATH was lifted" would credit + // an opt-in that was switched off. + let text = verdict(&opted_in_and_lifted_but(false, true)); + + assert!( + text.contains("UNEXPECTED"), + "lifting without the opt-in is not the ordinary lifted verdict: {text}" + ); + assert!( + !text.contains("No evidence of a prefix-then-parse"), + "that conclusion is about how the opt-in behaves, and it was not on: {text}" + ); +} + +/// [`opted_in_and_lifted`] with the two halves of the opt-in overridden. +fn opted_in_and_lifted_but(manifest_aware: bool, registry_enabled: bool) -> Observation { + Observation { + manifest_aware, + registry_enabled: Some(registry_enabled), + ..opted_in_and_lifted() + } +} + +#[test] +fn a_shape_never_tried_below_the_ceiling_is_not_reported_as_a_fault() { + // Measured at a 215-character `%TEMP%`, before the 200-unit limit existed: + // every attempt opened, nothing failed, and the report announced "an + // apparatus or platform problem". The shapes do not cross the ceiling + // together -- `..` carries an extra `\b\..` -- so a deep enough current + // directory pushed it over while plain was still under. + // + // No longer reachable from `%TEMP%`, which is now capped well below that. + // Kept for the same reason as the case below: a change to `shallow` or + // `SEGMENT` would put it back, and nothing else would notice. + let attempts = vec![ + below(Shape::Plain, true), + // No below-ceiling attempt for `..` at all: it is over even when short. + above(Shape::DotDot, true, 0), + below(Shape::ForwardSlash, true), + above(Shape::Plain, true, 0), + above(Shape::DotDot, true, 0), + above(Shape::ForwardSlash, true, 0), + ]; + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("never tried"), + "the missing baseline is the fact, and it is not a failure: {text}" + ); + // Only the live half of this is kept. The original also forbade "apparatus or + // platform problem", which was the pre-fix wording -- and since that string no + // longer exists anywhere the renderer can emit, the conjunct was trivially + // true and would have stayed green under any regression. An assertion that + // cannot fail is worse than none: it reads as coverage. + assert!( + !text.contains("apparatus is wrong"), + "nothing failed, so naming a fault sends the reader hunting for one: {text}" + ); +} + +#[test] +fn a_shape_that_failed_below_the_ceiling_is_reported_as_a_fault() { + // The other half of the split. Here something really did fail where it should + // not have, and the report must say so rather than blaming the ceiling. + let attempts = vec![ + below(Shape::Plain, true), + below(Shape::DotDot, false), + below(Shape::ForwardSlash, true), + above(Shape::Plain, false, ERROR_PATH_NOT_FOUND), + above(Shape::DotDot, false, ERROR_PATH_NOT_FOUND), + above(Shape::ForwardSlash, false, ERROR_PATH_NOT_FOUND), + ]; + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("apparatus is wrong"), + "a shape failing below the ceiling is a broken apparatus: {text}" + ); + assert!( + !text.contains("documented reading is wrong"), + "a broken apparatus cannot support a claim about the documentation: {text}" + ); +} + +#[test] +fn a_long_failure_that_is_not_a_length_refusal_yields_no_verdict() { + // Both headline verdicts once branched on "did not open" alone, so a sharing + // violation or an access denial produced a claim about `MAX_PATH` from + // evidence bearing on neither. The crate already owned the discriminator -- + // `is_refusal` -- and was spending it on a parenthetical in the table. + let mut attempts = healthy_below(); + attempts.extend(all_above(false, ERROR_ACCESS_DENIED)); + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("NO VERDICT") && text.contains("not a length refusal"), + "an access denial says nothing about the ceiling: {text}" + ); + assert!( + !text.contains("documented reading is wrong"), + "that headline needs a length refusal, which this is not: {text}" + ); + // In context, not as a bare number: `ERROR_ACCESS_DENIED.to_string()` is "5", + // and a substring search for "5" is satisfied by any verdict that happens to + // contain the digit -- including the ceiling, 259. + assert!( + text.contains(&format!("(error {ERROR_ACCESS_DENIED})")), + "the unexpected error must be named, or it cannot be chased: {text}" + ); +} + +#[test] +fn a_reference_shape_never_tried_above_the_ceiling_yields_no_verdict() { + // "No long plain attempt opened" was read as "plain was refused", conflating a + // refusal with an attempt that was never made. Reproduced by forcing the deep + // level to 21, which is the only way to reach it: **no `%TEMP%` length can**, + // and saying otherwise would send the next reader looking for a host + // configuration that does not exist. + // + // The deep relative path is a fixed 370 units -- 40 levels of an 8-unit + // `SEGMENT` plus separators plus `target.txt` -- so the deep plain attempt is + // never below about 392 against a ceiling of 259. With the shipped `deep = 40` + // this state is unreachable, and the guard is defence against a future change + // to `deep` or `SEGMENT` rather than against anything a host can do. That is + // exactly why it is worth a test: nothing else would notice it rotting. + let attempts = vec![ + below(Shape::Plain, true), + below(Shape::DotDot, true), + below(Shape::ForwardSlash, true), + // A shallower tree: plain still lands under the ceiling at depth, so it is + // never tested above it. + below(Shape::Plain, true), + above(Shape::DotDot, false, ERROR_PATH_NOT_FOUND), + below(Shape::ForwardSlash, true), + ]; + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("NO VERDICT"), + "with no plain attempt above the ceiling there is nothing to conclude: {text}" + ); + assert!( + !text.contains("even the plain shape was refused"), + "plain was never tried above the ceiling, so it was not refused there: {text}" + ); +} + +#[test] +fn the_sharp_edge_names_only_shapes_that_worked_below_the_ceiling() { + // The probe's headline finding. It claims a shape "stopped resolving past the + // ceiling while working below it", and for a long time computed that from the + // long attempts alone -- so a shape that failed at *both* lengths would have + // been reported as the sharp edge, which is the opposite of what happened. + let attempts = vec![ + below(Shape::Plain, true), + below(Shape::DotDot, true), + below(Shape::ForwardSlash, true), + above(Shape::Plain, true, 0), + above(Shape::DotDot, false, ERROR_PATH_NOT_FOUND), + above(Shape::ForwardSlash, true, 0), + ]; + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("SHARP EDGE") && text.contains(Shape::DotDot.label()), + "`..` worked below and failed above, which is exactly the sharp edge: {text}" + ); + assert!( + !text.contains(Shape::ForwardSlash.label()), + "forward slashes resolved past the ceiling, so naming them is false: {text}" + ); +} + +#[test] +fn a_fully_opted_in_run_that_lifts_reports_no_prefix_then_parse() { + // The ordinary success, and the shape of the aware binary's real output. Its + // presence keeps the tests above honest: they forbid wrong sentences, and + // without this one the whole function could satisfy them by never concluding. + let text = verdict(&opted_in_and_lifted()); + + assert!( + text.contains("No evidence of a prefix-then-parse"), + "every shape resolved past the ceiling, which is the negative result: {text}" + ); + assert!( + !text.contains("NO VERDICT") && !text.contains("SHARP EDGE"), + "nothing was withheld and nothing broke: {text}" + ); +} + +#[test] +fn a_run_with_no_long_attempt_tested_nothing_and_says_so() { + // The degenerate case: no attempt reached the ceiling at all. Only a shallower + // tree can produce it -- a short `%TEMP%` cannot, because the deep attempts + // carry a fixed 370 units whatever the current directory costs. There is no + // finding here in either direction. + let text = verdict(&observation(true, true, healthy_below())); + + assert!( + text.contains("tested nothing"), + "without an attempt past the ceiling the run establishes nothing: {text}" + ); + assert!( + !text.contains("lifted") && !text.contains("SHARP EDGE"), + "no conclusion is available from attempts that never crossed: {text}" + ); +} + +#[test] +fn an_absent_registry_half_is_named_even_when_the_manifest_is_present() { + // The state `probe-long-path-aware` reaches on a machine whose administrator + // never set `LongPathsEnabled` -- which includes a stock CI runner, so this is + // the aware binary's likely output wherever this repository builds. The + // manifest alone does not opt in, so a refusal here is still the baseline, and + // the report has to name the *registry* as the missing half or the reader will + // check the one thing that is present. + let mut attempts = healthy_below(); + attempts.extend(all_above(false, ERROR_PATH_NOT_FOUND)); + let text = verdict(&observation(true, false, attempts)); + + assert!( + text.contains("BASELINE"), + "one half is not an opt-in, so this is still the baseline: {text}" + ); + assert!( + text.contains("LongPathsEnabled"), + "the missing half is the machine setting, and must be the one named: {text}" + ); + assert!( + !text.contains("no `longPathAware` manifest"), + "this binary *has* the manifest; naming it sends the reader to the wrong half: {text}" + ); +} + +#[test] +fn both_halves_absent_are_reported_as_neither_rather_than_as_one() { + // The commonest real-world state for the un-opted-in half: a machine without + // the registry value running a binary without the manifest. Naming only one + // would imply the other was in place. + let mut attempts = healthy_below(); + attempts.extend(all_above(false, ERROR_PATH_NOT_FOUND)); + let text = verdict(&observation(false, false, attempts)); + + assert!( + text.contains("BASELINE") && text.contains("neither half"), + "with both absent the report must say so, not pick one: {text}" + ); + assert!( + !text.contains("LongPathsEnabled") && !text.contains("no `longPathAware` manifest"), + "naming a single half implies the other was present: {text}" + ); +} + +#[test] +fn a_broken_apparatus_outranks_a_missing_baseline() { + // Both conditions at once: one shape failed below the ceiling (the apparatus + // is wrong) while another was never tried below it (no baseline). The order + // matters because only one message is emitted, and the apparatus fault is the + // one that invalidates the whole run -- reporting the missing baseline instead + // would describe a consequence and hide the cause. + let attempts = vec![ + below(Shape::Plain, false), + // No below-ceiling attempt for `..` at all. + above(Shape::DotDot, true, 0), + below(Shape::ForwardSlash, true), + above(Shape::Plain, false, ERROR_PATH_NOT_FOUND), + above(Shape::DotDot, true, 0), + above(Shape::ForwardSlash, true, 0), + ]; + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("apparatus is wrong") && text.contains(Shape::Plain.label()), + "the fault is the cause and must be what the reader is told: {text}" + ); + assert!( + !text.contains("never tried"), + "the missing baseline is a consequence here, and would hide the fault: {text}" + ); +} + +#[test] +fn a_run_with_nothing_below_the_ceiling_claims_nothing_about_the_apparatus() { + // The state every other test in this file misses, and the reason it lasted + // as long as it did: each of them seeds at least one below-ceiling attempt, so the + // sentence "every attempt that did land below the ceiling opened" always had + // something to be true *of*. With none, it is vacuously true and was still + // being offered as affirmative evidence that the apparatus was sound. + // + // Measured rather than imagined, at a 226-character `%TEMP%` -- but only + // before the 200-unit limit existed. Such a temporary directory put even the + // shallow attempts over the ceiling, and the unaware binary refused all six + // while the report announced that everything below the ceiling had opened. + // + // The limit now refuses that configuration up front, so no `%TEMP%` reaches + // this state; it takes a change to `shallow` or `SEGMENT`. Kept because that + // is exactly the change nothing else would catch. + let attempts = all_above(false, ERROR_PATH_NOT_FOUND); + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("No attempt landed below"), + "with nothing under the ceiling that is the fact to report: {text}" + ); + assert!( + text.contains("says nothing about the apparatus"), + "an untested apparatus must be described as untested, not as sound: {text}" + ); + assert!( + !text.contains("did land below the ceiling opened"), + "nothing landed below the ceiling, so that claim has no subject: {text}" + ); + // `apparatus fault:` and not `not an apparatus fault`: the latter spans a line + // break in the emitted text (`not an\n apparatus fault:`), so asserting it + // would be an assertion that cannot fail -- which is the defect an earlier + // round caught in this same file, and which I reintroduced writing this pair. + assert!( + !text.contains("apparatus fault:"), + "the run never exercised the apparatus, so it cannot clear it: {text}" + ); +} + +#[test] +fn a_run_with_something_below_the_ceiling_still_clears_the_apparatus() { + // The control. Without it, deleting the non-vacuous branch entirely would + // leave the test above green, since it asserts absences. + let attempts = vec![ + below(Shape::Plain, true), + above(Shape::DotDot, false, ERROR_PATH_NOT_FOUND), + below(Shape::ForwardSlash, true), + above(Shape::Plain, false, ERROR_PATH_NOT_FOUND), + above(Shape::ForwardSlash, false, ERROR_PATH_NOT_FOUND), + ]; + let text = verdict(&observation(true, true, attempts)); + + assert!( + text.contains("did land below the ceiling opened") && text.contains("apparatus fault:"), + "plain and forward slashes opened below, so the apparatus is demonstrably sound: {text}" + ); + assert!( + !text.contains("No attempt landed below"), + "two attempts did land below the ceiling: {text}" + ); +} + +// --------------------------------------------------------------------------- +// `body` -- everything the report says once the measurement is in hand. +// +// Untestable until `render` was split, because `render` performs the +// measurement itself and so cannot be called without building a directory tree +// and moving the process's current directory. What follows is not formatting +// coverage: some of it fixes defects a review round had to find by reading a +// probe's output, which is the slowest possible detector. +// --------------------------------------------------------------------------- + +/// The report body for an observation, as a string. +fn body_of(observation: &Observation) -> String { + let mut out = String::new(); + super::body(&mut out, observation); + out +} + +#[test] +fn the_ceiling_column_prints_the_number_it_actually_compares() { + // The header read `> MAX` beside a module defining `MAX_PATH` as 260, while + // the comparison was against 259. Those differ at exactly one length -- the + // length this probe exists to be exact about -- so a row could read `260 yes` + // under a heading meaning "over 260". + let text = body_of(&observation(true, true, healthy_below())); + + assert!( + text.contains("> 259"), + "the column must name the ceiling it compares against: {text}" + ); + assert!( + !text.contains("> MAX"), + "`> MAX` is read as 'over 260', which is the one wrong reading: {text}" + ); +} + +#[test] +fn a_not_found_below_the_ceiling_is_not_called_a_length_refusal() { + // `is_refusal` separates a length rejection from a genuine absence and says + // nothing about length itself. Applied to every failed attempt, it annotated + // under-ceiling rows "so this is the length refusal" -- blaming the ceiling + // for a file that was simply missing. + let attempts = vec![below(Shape::Plain, false)]; + let text = body_of(&observation(true, true, attempts)); + + assert!( + text.contains("BELOW the ceiling") && text.contains("apparatus is wrong"), + "a not-found under the ceiling is an apparatus fault: {text}" + ); + assert!( + !text.contains("this is the length refusal"), + "length is the one thing that annotation asserts and this row is not over: {text}" + ); +} + +#[test] +fn a_not_found_above_the_ceiling_is_called_a_length_refusal() { + // The control for the test above: without it, deleting the annotation + // entirely would leave that one green, since it asserts an absence. + let attempts = vec![above(Shape::Plain, false, ERROR_PATH_NOT_FOUND)]; + let text = body_of(&observation(true, true, attempts)); + + assert!( + text.contains("this is the length refusal"), + "over the ceiling, a not-found on a file that exists is the length refusal: {text}" + ); +} + +#[test] +fn an_absent_registry_half_is_flagged_before_any_row_is_read() { + // The machine half is a precondition for the whole run, so the warning has to + // come before the table rather than after it -- a reader who has already + // interpreted the rows has been misled by the time a footnote arrives. + let text = body_of(&observation(true, false, healthy_below())); + + let warning = text + .find("machine half of the opt-in is absent") + .expect("the absent registry half must be reported"); + let table = text + .find("shape") + .expect("the table header must be present"); + assert!( + warning < table, + "the warning must precede the rows it qualifies: {text}" + ); +} + +#[test] +fn an_apparatus_failure_stops_the_report_before_the_rows() { + // When the apparatus failed the attempts are meaningless, and printing them + // under a heading that invites comparison is worse than printing nothing: + // the numbers look like findings. + let attempts = vec![above(Shape::Plain, false, ERROR_PATH_NOT_FOUND)]; + let observation = Observation { + apparatus_error: Some("could not build the tree".to_string()), + ..observation(true, true, attempts) + }; + let text = body_of(&observation); + + assert!( + text.contains("APPARATUS FAILED") && text.contains("could not build the tree"), + "the failure and its cause must both be reported: {text}" + ); + assert!( + !text.contains("shape") && !text.contains("REFUSED"), + "rows gathered by a broken apparatus must not be shown at all: {text}" + ); +} + +#[test] +fn a_refused_run_claims_nothing_about_the_registry() { + // A run refused for an over-long temporary directory never issues the + // registry query, because reading it spawns a process and that is one of the + // calls that hangs. Reporting the unread flag as `false` made the report say + // `LongPathsEnabled : unset or 0` and "the machine half of the opt-in is + // absent" on a host where the value is 1 -- a measured-sounding claim about a + // query that was never made, immediately above an apparatus error saying + // nothing below described the machine. + let observation = Observation { + manifest_aware: true, + registry_enabled: None, + attempts: Vec::new(), + apparatus_error: Some("%TMP% is too long".to_string()), + }; + let text = body_of(&observation); + + assert!( + text.contains("not consulted"), + "an unread setting must be reported as unread: {text}" + ); + assert!( + !text.contains("unset or 0"), + "that is a claim about the machine, and no query was issued: {text}" + ); + assert!( + !text.contains("machine half of the opt-in is absent"), + "warning that it is absent asserts the same unmade measurement: {text}" + ); + assert!( + text.contains("APPARATUS FAILED"), + "the reason the run established nothing must still be reported: {text}" + ); +} + +#[test] +fn a_registry_half_that_was_read_and_is_off_is_still_flagged() { + // The control. `Some(false)` is a real measurement and must keep warning -- + // without this, suppressing the warning entirely would satisfy the test above. + let text = body_of(&observation(true, false, healthy_below())); + + assert!( + text.contains("machine half of the opt-in is absent"), + "a registry half that was read and is off is a real finding: {text}" + ); + assert!( + text.contains("unset or 0") && !text.contains("not consulted"), + "it was consulted, and the report should say what it found: {text}" + ); +} + +#[test] +fn a_shape_that_cannot_survive_verbatim_parsing_is_not_called_a_length_refusal() { + // The annotation for a not-found past the ceiling used to say "the target + // provably exists, so this is the length refusal" for every shape. For `..` + // and forward slashes that is the ambiguity the probe exists to resolve + // rather than a fact about the run: if the system prepends `\\?\` past the + // ceiling, those shapes stop being resolved and the path as written names + // something never created -- a genuine absence, and the sharp edge itself. + // Calling it the length refusal files the finding as its own control. + let attempts = vec![above(Shape::DotDot, false, ERROR_PATH_NOT_FOUND)]; + let text = body_of(&observation(true, true, attempts)); + + assert!( + text.contains("either the length refusal or this shape ceasing to"), + "for a shape `\\\\?\\` would break, the cause is genuinely undecided here: {text}" + ); + assert!( + !text.contains("so this is the length refusal"), + "asserting the length refusal would rule out the sharp edge by fiat: {text}" + ); +} diff --git a/tools/check-encoding.ps1 b/tools/check-encoding.ps1 index 56c635b2..45a68a72 100644 --- a/tools/check-encoding.ps1 +++ b/tools/check-encoding.ps1 @@ -48,7 +48,11 @@ $Patterns = @( $TextExtensions = @( '.rs', '.toml', '.md', '.txt', '.json', '.yaml', '.yml', '.ps1', '.psm1', '.psd1', '.sh', '.cfg', '.ini', '.ts', - '.lock', '.gitignore', '.gitattributes', '.vscodeignore' + '.lock', '.gitignore', '.gitattributes', '.vscodeignore', + # Win32 side-by-side manifests. XML that declares its own `encoding="UTF-8"` + # and is read by the linker, so a BOM or a mojibake edit would be consumed + # rather than merely displayed. + '.manifest' ) function Test-IsTextFile([string]$file) {