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) {