Skip to content

Validate the Windows final component through a same-handle reparse-point open (#703) - #739

Merged
leynos merged 21 commits into
mainfrom
issue-703-validate-the-windows-final-component-through-a-same-handle-reparse-point-open
Sep 20, 2026
Merged

leynos merged 21 commits into
mainfrom
issue-703-validate-the-windows-final-component-through-a-same-handle-reparse-point-open

Conversation

@leynos

@leynos leynos commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes #703.

open_file_checked decided the Windows file-type and symlink policy from a
pre-open symlink_metadata lookup and then opened the path as a separate step.
Those two operations are not atomic: a final component that is an ordinary file
at check time can be replaced before the open, so a caller able to write to the
containing directory can win the race and have the read follow a prohibited
reparse point. Unix never had this window, because O_NOFOLLOW is applied to
the open itself.

The Windows path now reaches the same guarantee from inside the open. The final
component is opened with FILE_FLAG_OPEN_REPARSE_POINT, so the open does not
traverse the reparse point and the handle that comes back is the reparse
point; the policy decision is then read from that handle's own attributes, and
the caller reads bytes through the same handle. A concurrent rename changes
which entry the path names, but cannot change what an already-open handle
refers to, so the decision and the read cannot diverge.

Two details worth review:

  • The policy rejects every reparse tag, not just the ones std calls
    symlinks.
    FileType::is_symlink tests the tag value: it is true only for
    name-surrogate tags (bit 29 set) — symlinks, junctions, volume mount points —
    and false for every other tag, such as a deduplication or cloud placeholder,
    for which std also reports is_file() == true. Testing
    FILE_ATTRIBUTE_REPARSE_POINT instead asks "is this a reparse point at all",
    which needs no enumeration of tags and refuses ones a future Windows release
    may mint.
  • FILE_FLAG_BACKUP_SEMANTICS is unconditional, so a directory can be
    opened and then rejected by the existing shared regular-file check with the
    documented diagnostic, rather than the open failing with a different error
    than Unix reports.

reject_windows_symlink is deleted; nothing replaces its pre-open lookup.

Why no unsafe, no new dependency, no lint relaxation

The original plan anticipated raw Win32 FFI (CreateFileW +
GetFileInformationByHandleEx), a windows-sys dependency, an unsafe_code
downgrade, and a dylint.toml exclusion. None proved necessary: cap_std's
OpenOptionsExt::custom_flags is OR-ed straight into dwFlagsAndAttributes,
and MetadataExt::file_attributes() is populated from
BY_HANDLE_FILE_INFORMATION read from the open handle. Both are safe, public,
and already reachable through the cap_std::fs_utf8 re-exports the module
uses. This is also the same trait family the Unix arm already uses to set
O_NOFOLLOW, so the two platforms reach their guarantees through parallel
mechanisms. CreateFileW additionally has no directory-handle-relative form,
so an FFI route would have discarded the capability sandbox for an ambient
absolute path. Recorded in ADR-032.

Testing

Four layers carry the guarantee: a compile-time assertion, a unit test, an
integration test, and the opt-in test.

Compile time, on every Windows build. A const _: () = { ... } block in
windows_reparse_tests.rs pins both branches of open_flags and both outcomes
of is_prohibited_reparse_point. Runtime tests in that file execute only on a
Windows host, so a regression could have reached a merge on the strength of a
green Linux run; a const assertion does not, because rustc evaluates it
whenever the module is compiled and Windows / lint-windows compiles it on
every push through cargo clippy --all-targets. It fails the Windows build in
seconds rather than a test run in minutes. Added in response to the Codex
review's "Compile-Time / UI" finding.

Integration, all four filters. A Windows-only test asserts that a junction
fixture — created with mklink /J, which needs no privilege — is rejected by
contents, linecount, hash, and digest under the default policy. The
fixture follows the repository's "create the requested file type or skip
because that file type is unavailable" rule: require_real_junction asserts
the entry really carries FILE_ATTRIBUTE_REPARSE_POINT before rendering, so
the test cannot pass against a plain directory. Because the refusal happens on
the opened handle, it surfaces as the regular-file diagnostic; a traversal
would have rendered the target directory's entries instead of erroring at all.

Unit, on the handle. The integration test alone cannot detect a dropped
FILE_FLAG_OPEN_REPARSE_POINT, because std reports a junction as a symlink
and the shared regular-file check refuses the resolved directory anyway. A
handle-level test therefore asserts the property where it actually shows: the
default policy's open of a junction returns a handle carrying the reparse bit,
and the ordinary target directory is asserted not to carry one, so the
refusal is shown to be about the link rather than about directories generally.
That is the only test that fails if the flag stops being passed.

Opt-in. follow_symlinks_opt_in_reads_the_link_target covers the retained
follow path end to end using a relative-target file symlink. A junction
cannot serve here: mklink /J records an absolute target, and cap_std's
resolver refuses an absolute link destination outright (escape_attempt(),
reported as PermissionDenied) — under either policy. The handle-level test
therefore takes the junction through the ambient authority, which is the only
way to reach the reparse point itself. This is recorded in ADR-032 under "Known
risks and limitations".

Note the mechanism precisely, because an earlier revision of this body and of
the docs stated it wrongly. The resolver does not compare the resolved path
against the capability root; it refuses a link destination containing a prefix
or root component, and so cannot distinguish an absolute target that stays
inside from one that does not. Relativity of the link target, not containment
of the resolved path, is what is tested.
Measured on Linux rather than
inferred: two symlinks pointing at the same file inside one capability, one
written relatively and one absolutely, under follow_symlinks=true — the
relative link opened and the absolute link was refused with "a path led outside
of the filesystem". Same file, same containment, different result; only the
spelling of the target differed.

Windows verification: what CI already does, and what the probe crate is for

Native Windows CI compiles and lints every Windows-gated line, tests included.
Windows / lint-windows runs make lint-clippy, which expands to
cargo clippy --workspace --all-targets --all-features -- -D warnings, then
Whitaker's dylint suite over the same target and feature selection.
--all-targets compiles the library's cfg(test) module and the integration
test targets, so windows_reparse.rs, windows_reparse_tests.rs, and the
junction fixture in file_type_tests.rs are all built on Windows itself under
-D warnings. That job is green on this head.

For most of this branch's life that was the widest evidence available, because
the test lane never reached the module. It is no longer the widest: the test
lane now executes the cases as well, lower on this page. It remains the half
that compiles every Windows-gated line, and the two are described separately
because compiling a test under -D warnings is not running it.

The local probe crate below is therefore the development loop, not the
guarantee. It exists because the main crate cannot be cross-compiled on this
host (ring needs MSVC lib.exe), so Windows-gated edits were iterated
against it between CI runs. Its value is that it returns a Windows lint verdict
in seconds; passing --target x86_64-pc-windows-msvc after dylint's --
separator reaches the same lints the CI job applies:

cargo dylint --all --no-deps -- -p probe-capst --target x86_64-pc-windows-msvc --all-targets --all-features
cargo clippy --target x86_64-pc-windows-msvc --lib --tests --all-features -- -D warnings

Both are clean, and both are required, because each is blind to what the
other checks. cargo dylint shells out to cargo check, so it applies the
Whitaker lints and no clippy lint at all; cargo clippy does the reverse.
Running only the first leaves every clippy lint on Windows-only code
unvalidated. Neither direction is inferred — each was falsified by injecting a
defect into the mirrored test module and confirming a non-zero exit:

Injected defect cargo dylint cargo clippy
Some(1u32).unwrap() passed, exit 0 failed, unwrap_used
std::fs::metadata(".") failed, no_std_fs_operations passed

Both tools were re-run clean on this head after the defects were reverted. The
first row also corrects this body's earlier claim that clippy is the weaker
tool: on this codebase the relationship is genuinely complementary, and clippy
alone is what let two Whitaker findings reach CI earlier on this branch
(module_must_have_inner_docs and five no_expect_outside_tests), both now
fixed.

The Windows test lane executes this PR's junction tests, and they pass.
This section said the opposite until the rebase, because
Windows / build-test-windows was red repository-wide until 061182b1 landed
on main with the write-side shutdown fix for the network-fixture race tracked
as #743. Before that fix the
lane halted inside stdlib::network and never reached stdlib::path: on
2d8e5305 and on 34553e03 alike the run ended at 1078/2901 tests and the
string windows_reparse appeared zero times in the job log. This branch is
now rebased onto 061182b1, and on head 5d2dab68 the lane completes:

Summary [ 341.726s] 2906 tests run: 2906 passed (1 slow), 2 skipped

2898 of those are main's; the extra 8 are this branch's. The new cases:

PASS netsuke-build stdlib::path::windows_reparse::tests::constants_match_the_documented_abi_values
PASS netsuke-build stdlib::path::windows_reparse::tests::default_policy_opens_without_traversing_and_permits_directories
PASS netsuke-build stdlib::path::windows_reparse::tests::policy_refuses_surrogate_and_non_surrogate_tags_alike
PASS netsuke-build stdlib::path::windows_reparse::tests::the_default_handle_is_the_junction_not_its_target
PASS read_policy_filters::file_type_tests::reading_filters_reject_a_junction::case_1_contents
PASS read_policy_filters::file_type_tests::reading_filters_reject_a_junction::case_2_linecount
PASS read_policy_filters::file_type_tests::reading_filters_reject_a_junction::case_3_hash
PASS read_policy_filters::file_type_tests::reading_filters_reject_a_junction::case_4_digest

The result easiest to fake is the one worth checking hardest: these tests skip
their own fixture when the host has no cmd.exe to reach mklink through, so a
green run does not by itself prove a junction was built. It is worth being
exact about this, because my first draft of this section claimed the log proved
it and it does not.

This repository's skip convention returns Ok(()), so a skipped fixture is
recorded as a pass, not a skip. Neither total can distinguish "asserted
against a junction" from "quietly did nothing". Two things can.

The fixtures have exactly one quiet arm, and it is narrow. Both
junction_fixture variants return None only on ErrorKind::NotFound from
Command::new("cmd"); a mklink that exits non-zero trips an ensure! quoting
its stderr, and any other spawn error propagates. On a host where cmd.exe can
be spawned, the only ways to finish are "the junction was created" or "the test
failed". cmd.exe ships with windows-latest, so that arm is not reached.

A created junction is checked before it is used. require_real_junction
reads the entry's attributes without following the link and fails unless
FILE_ATTRIBUTE_REPARSE_POINT is set, precisely so a plain directory cannot
stand in for a reparse point.

The residual uncertainty is that the first point reasons about the runner image
rather than measuring it: nextest hides the captured output of passing tests by
default — the success-output = "immediate" entries in .config/nextest.toml
cover three unrelated test groups, and the CI lane passes no --success-output
— so the log never records that cmd was spawnable. The absence of the skip
lines proves nothing on its own and is not relied on. What the run count does
establish is that the cases ran at all: main reports 2898 tests, this head
2906, and all eight new cases appear as PASS.

Windows / lint-windows is green on the same head; it is the compile-time half,
and build-test-windows is now the runtime half.

What the Windows lane still cannot show

A test cannot force a rename to land between two filesystem calls, because the
change removed the second call. The decision is read from the handle the read
uses, so the absence of a window is an argument from handle semantics, not
something a test can schedule. The runtime behaviour is now demonstrated on the
platform it governs; the construction argument is what covers the race itself.

Pre-existing Windows failure that the rebase carries the fix for

Recorded because this branch's earlier heads carried it, not because it is live.
Windows / build-test-windows failed for every PR and for main itself,
from a race in a test introduced by
8e09a3a4 Bump ureq from 2.12.1 to 3.4.0 (#438):

stdlib::network::redirect::error_tests::protocol_failures_are_classified_from_a_live_response
Error: a malformed status line should surface as a protocol error,
       got io: An established connection was aborted by the software in your host machine. (os error 10053)

malformed_status_line_failure wrote a malformed status line from a server
thread and joined it, but did not drain the request or shut the write side down
first, so the client's connection was reset before the malformed response was
read. Fixed on main by 061182b1 Complete local HTTP fixture responses with a write-side shutdown (#743) (#749), which this branch is rebased onto.

Checklist

  • Windows default path rejects symlinks, mount points, and other prohibited
    reparse points without a separate pre-open metadata check
  • Unix O_NOFOLLOW path unchanged; open_file_checked remains the single
    shared entry point for all four filters
  • Regression tests accompany the change, with the special-file fixture
    creating the requested file type rather than substituting a regular file
  • unsafe_code stays at forbid; no new dependency; no lint relaxation
  • Local commit gates pass on head 5d2dab68: check-fmt (142 files
    unchanged), lint, typecheck, markdownlint (143 files, 0 errors),
    nixie (all diagrams validated), doc-coverage (98.79% vs an 80%
    threshold), doctest (123 passed, 0 failed), and — for the first time on
    this branch — make test end to end.
  • make test — green, 3225 tests run: 3225 passed, 5 skipped on
    head 5d2dab68, a full run with no cancellation. This is the first head
    of the branch on which the gate passes whole: earlier heads failed
    locale_stub_ui_tests::harness_compiles_under_a_split_build_dir against
    nextest's 300s budget. That test passed here in 57.9s. main did not
    touch it (none of the six incoming commits names it), so the difference is
    load: the earlier 449s standalone / 333s near-idle measurements were this
    host's load sensitivity, not a defect. Recorded because the earlier heads'
    checklist entries said the opposite, and a reader comparing heads deserves
    to know which way it moved and why.
  • Windows / lint-windows passes on CI, green on head 5d2dab68 as it was
    on 34553e03, 87c41ee9, 4eb1f08e, 2d8e5305, and 0b233a16. It is
    not only a lint result: it runs cargo clippy --workspace --all-targets --all-features -- -D warnings, and --all-targets compiles the
    library's #[cfg(test)] children, so the Windows-gated module, its
    tests, and the junction fixture are all built natively on Windows under
    -D warnings.
  • Windows test coverage for the new cases — achieved on head
    5d2dab68
    . Windows / build-test-windows is green and the eight new
    cases execute there, including the four reading_filters_reject_a_junction
    cases and the_default_handle_is_the_junction_not_its_target. This was
    the last open item on the branch; it closed when the rebase onto
    061182b1 brought in the Windows: ureq 3.4.0 classifies a malformed status line as a connection abort, failing redirect error_tests and halting the suite at 1080/2891 #743 fix. See "The Windows test lane executes
    this PR's junction tests, and they pass".

Defects found after the PR opened

Recorded so the history is readable rather than tidy. All three were caught by
CI on this branch rather than by local gates, and the third was found while
reading a job log for an unrelated reason.

  1. A wrong mechanism in the docs. Three review findings said
    follow_symlinks=true does not override capability containment. The premise
    was right and the wording I had shipped was wrong in a specific way: it said
    a link is refused when its destination leaves the capability, when in fact
    the resolver refuses an absolute link destination outright and never
    compares the resolved path to the capability root. Corrected in all four
    places that stated it, with the superseded wording quoted in ADR-032 so a
    reader who met the earlier claim can see which was wrong and why.
  2. Non-canonical Markdown. A hand-wrapped paragraph in users-guide.md was
    under 80 columns but broke at the wrong position, so make check-fmt failed
    in both build-test and Windows / lint-windows. Fixed by running
    mdtablefix over the file rather than wrapping by hand — the tool's answer
    is the only one the gate accepts. Only the wrap point moved.
  3. The same Markdown defect again, in the same file, two commits later, and
    the most useful entry here. Reviewing the CI job logs for an unrelated
    reason, I found this PR had understated its own Windows verification: the
    record pointed readers at a throwaway /tmp probe crate as the main route,
    when Windows / lint-windows already runs cargo clippy --workspace --all-targets --all-features -- -D warnings, and --all-targets compiles
    the library's #[cfg(test)] children — so the Windows-gated module, its
    tests, and the junction fixture are all built on Windows natively under
    -D warnings. Correcting that in ADR-032 and the PR body was worth doing.
    The correction itself then failed check-fmt at the identical
    make: *** [Makefile:314: check-fmt] Error 1, because I hand-wrote the new
    prose. Nothing interesting about that failure; the point is that knowing the
    rule did not prevent it, twice.

None of the three changed behaviour, and all three are fixed on the current
head. The third narrowed a claim as well as widening one: compiling a test
under -D warnings does not run it, so this PR still claims no Windows CI
execution of the new tests.

Review findings, and what happened to each

The final coderabbit review --agent pass on this PR returned five findings and
posted no PR comments, so they are recorded here rather than left invisible.
All five are the same class — "this document exceeds the repository's 400-line
limit, decompose it" — and four have a premise the repository does not support.

The cap is a code-file rule. AGENTS.md:31 reads "No single code file
may be longer than 400 lines". Its only enforcement is
pyproject.toml:175, max-module-lines = 400 under [tool.pylint.main] —
pylint is a Python linter and never reads Markdown. No Markdown gate imposes a
line cap (.markdownlint-cli2.jsonc sets only MD004/MD010/MD013/MD029, and no
Makefile target counts lines in a .md).

Four findings point at documents this PR did not push over any limit:

File base reviewed head this PR's net
developers-guide.md 7299 7315 +16
netsuke-design.md 4018 4021 +3
users-guide.md 2010 2010 0

The only file here this PR lengthened is developers-guide.md, by 16 lines,
in a 7300-line document. All three were far over 400 long before this branch
existed, and none of the three is shorter than its base. Decomposing a
7000-line guide is a real piece of work, but it is not this PR's change and
doing it here would bury a filesystem hardening diff under thousands of moved
lines. Not actioned, deliberately.

One finding was right, and is fixed. stdlib-yaml-and-jinja-guide.md sat at
397 lines on main; my prose took it to 405. That is a threshold this
branch actually crossed, so the added text was trimmed back: the policy
paragraph went from 13 text lines on main to 21, then to 15 after the trim,
and the file landed at 400. The dropped material enumerated reparse tags and
restated the follow_symlinks containment behaviour that
users-guide.md#configure-file-reading-limits already documents in full. The
guide already pointed readers at the same manual once on main, at line 379
(#configure-network-access, now line 382), so citing the manual from this
paragraph follows the file's existing idiom; the new #configure-file-reading-limits
anchor is what this branch adds. The guide is now at 400.

The distinction matters for reviewability rather than for line counts: a rule
whose scope is "code file" does not become a defect when it is aimed at prose
the PR did not lengthen.

Two notes on the review process itself. The findings visible via
coderabbit review findings included three symlink-related items restating "an
absolute target that leaves the capability" — the exact wrong mechanism this
branch corrects. Those are stale cache from an earlier session (timestamped
before either review run, carrying no git.json); both runs made against this
branch persisted zero comments, and the wording they quote is no longer in the
tree. They are not output about this head and were not acted on.

The one actionable review finding, and how it finally closed

The Codex finding above ("Update user docs for the reparse-point policy") was
the only one that survived scrutiny, and it was right about all three files.
Recording the reasoning here because my first response to it was wrong, and the
error is more instructive than the fix.

The finding was made against 4eee40e7, which is not an ancestor of the
current head
. That commit belongs to an earlier epoch of this branch, forked
from a273fad3; the branch was later rebuilt on a different base. Reading a
finding against the current tree therefore answers a different question than
the one the reviewer asked, and I initially did exactly that. Against
4eee40e7, all three sub-claims hold:

users-guide.md  "reparse" appears 0 times — described symlink rejection only
guide:169       "while on Windows a check made before the open reuses the
                 not-a-regular-file diagnostic"
audit:141       "a pre-open `symlink_metadata` check on Windows"

The implementation had already moved to the same-handle design at that commit
(reject_windows_symlink is gone from fs_utils.rs), and the review's own
subject is a compile-time finding against that commit — so the docs were
stale relative to the code, exactly as the reviewer said. The fix landed later
the same day in e8ebc5f4 (docs: describe the Windows reparse-point policy in the user-facing guides), which added the all-tags paragraph to
users-guide.md and replaced the pre-open wording in the other two files. The
review was submitted at 16:21Z and that commit is timestamped 18:08Z, so the
ordering is consistent with the finding having prompted the fix.

One sub-claim was still narrower than it looked, and CodeRabbit's second pass
found the remainder: two sentences described the opt-in in symlink-only
terms, while on Windows it governs every reparse tag —

stdlib-yaml-and-jinja-guide.md:178  `follow_symlinks=true` permits the final component to be a symlink
security-network-command-audit.md:153  `follow_symlinks=true` opt-in permits link following

That is a genuine defect: a reader on a cloud-placeholder or deduplicated file
would conclude the opt-in does not apply to them. Both now say the opt-in
"waives that final-component refusal", which is the users-guide.md phrasing,
and each paragraph's antecedent defines the refusal precisely — every reparse
tag on Windows — a few lines above. The change is net-zero on line count in
both files, so the branch does not cross the 400-line threshold it just came
under.

This is the useful shape to remember, in the opposite direction to the one I
first wrote down: a finding can be correct and still look wrong, because the
tree in front of you has moved past it. Check the commit the finding was made
against before deciding its premise is inverted — especially after a rebase,
where the reviewed commit may not be in the lineage at all.

References

Summary by Sourcery

Harden Windows file-reading policy by opening final components without traversing reparse points and validating the resulting handle before reading.

Bug Fixes:

  • Close the Windows final-component check-then-open race by validating reparse-point status from the same handle used for reading.
  • Reject all Windows reparse points under the default file-reading policy while preserving opt-in support for supported relative-target symlinks.

Enhancements:

  • Preserve capability-based filesystem access and existing regular-file diagnostics by using safe cap_std APIs and unconditional directory-open support.
  • Add Windows-specific unit and integration coverage for junction handling across all four file-reading filters.

Documentation:

  • Document the Windows same-handle reparse-point policy, its capability-resolution limitations, and the architectural decision in ADR-032.

Tests:

  • Add handle-level tests proving the default Windows open returns the junction itself rather than its target.
  • Add Windows integration tests verifying contents, linecount, hash, and digest reject junctions.

@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: e5be07af-a458-48b8-b6d4-21919cb4e3c3

📥 Commits

Reviewing files that changed from the base of the PR and between a273fad and 4eee40e.

📒 Files selected for processing (11)
  • docs/adr-027-windows-reparse-point-same-handle-open.md
  • docs/contents.md
  • docs/developers-guide.md
  • docs/netsuke-design.md
  • src/stdlib/path/fs_utils.rs
  • src/stdlib/path/mod.rs
  • src/stdlib/path/windows_reparse.rs
  • src/stdlib/path/windows_reparse_tests.rs
  • tests/std_filter_tests/read_policy_filters/file_type_tests.rs
  • tests/std_filter_tests/read_policy_filters/mod.rs
  • tests/std_filter_tests/support.rs
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • leynos/monotony (auto-detected)
  • leynos/whitaker (auto-detected)
  • leynos/rstest-bdd (auto-detected)
  • leynos/mdtablefix (auto-detected)
  • leynos/typos-config-builder (auto-detected)
  • leynos/ortho-config (auto-detected)
  • leynos/lading (auto-detected)
  • leynos/shared-actions (auto-detected)
  • leynos/nixie (auto-detected)
  • leynos/ansible (auto-detected)

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


Summary

  • Replace the Windows pre-open symlink_metadata check with same-handle validation.
  • Open the final path component with FILE_FLAG_OPEN_REPARSE_POINT.
  • Reject reparse points and non-regular files using metadata from the opened handle.
  • Preserve the Unix O_NOFOLLOW path and the shared open_file_checked entry point.
  • Add Windows coverage for symlinks, junctions, reparse tags, and all four read filters.
  • Add ADR-027 and update the developer and design documentation.
  • Skip junction tests when the required Windows fixture is unavailable.
  • Record the existing Windows test-lane limitation caused by a pre-existing network test failure.

Related issue

  • #703 — Validate the Windows final component through a same-handle reparse-point open.

Walkthrough

Changes

Windows reparse-point validation

Layer / File(s) Summary
Handle-based reparse policy
src/stdlib/path/windows_reparse.rs, src/stdlib/path/mod.rs
Apply Windows reparse-aware flags. Detect prohibited reparse points from opened-handle attributes.
File-opening integration
src/stdlib/path/fs_utils.rs
Use same-handle validation and remove the separate symlink_metadata check.
Junction verification and documentation
src/stdlib/path/windows_reparse_tests.rs, tests/std_filter_tests/..., docs/...
Test symlink flags, reparse tags, junctions, filter errors, and document the revised behaviour.

Sequence Diagram(s)

sequenceDiagram
  participant Filter
  participant open_file_checked
  participant WindowsHandle
  participant Reader
  Filter->>open_file_checked: request file open
  open_file_checked->>WindowsHandle: open with reparse-aware flags
  WindowsHandle-->>open_file_checked: return handle attributes
  open_file_checked->>WindowsHandle: reject prohibited reparse point
  open_file_checked->>Reader: provide validated handle
Loading

Suggested labels: Issue

Priority: ➖ Normal

Change: Bug fix · Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 13 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
User-Facing Documentation ⚠️ Warning Treat the user-facing documentation as incomplete. The pull request changes Windows contents, linecount, hash, and digest behaviour: the default policy now rejects every final-component `FILE_… Update docs/users-guide.md in the file-reading section. State that, on Windows, the default policy rejects every final-component reparse point, including symlinks, junctions, volume mount points, and other reparse tags, and that only the …
Testing (Compile-Time / Ui) ⚠️ Warning Require a compile-time test. The PR adds the Windows pub(super) const fn functions open_flags and is_prohibited_reparse_point in src/stdlib/path/windows_reparse.rs, which introduces compile-ti… Add a Windows-gated trybuild compile-pass test, or a clear Rust compile-time equivalent, that evaluates open_flags and is_prohibited_reparse_point in const contexts and verifies both policy branches and representative attribute values…
✅ Passed checks (13 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Accept the implementation for issue #703. open_file_checked applies FILE_FLAG_OPEN_REPARSE_POINT on the Windows default path, reads attributes from the returned handle, rejects every reparse-point…
Out of Scope Changes check ✅ Passed Keep the documentation, ADR, unit tests, integration tests, and fixture helpers. These changes document or verify the same-handle Windows reparse-point policy required by issue #703. No unrelated prod…
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 22 functions across 7 files. (4 skipped: 4 …
Testing (Overall) ✅ Passed Pass the testing check. The pull request adds substantive Windows coverage. the_default_handle_is_the_junction_not_its_target creates a real mklink /J junction, opens it with the default flags, ch…
Developer Documentation ✅ Passed PASS. The changed Windows file-opening boundary is documented in docs/developers-guide.md, including the windows_reparse abstraction, Windows open flags, handle-based reparse validation, diagnosti…
Module-Level Documentation ✅ Passed Pass the module-level documentation check. The new windows_reparse and windows_reparse_tests modules start with clear //! documentation that states their purpose, utility, and relationship to th…
Testing (Unit And Behavioural) ✅ Passed Mark this check PASS. The unit tests cover flag selection, ABI constants, surrogate and non-surrogate reparse tags, ordinary-file attributes, and rejection from a real junction handle. The Windows beh…
Testing (Property / Proof) ✅ Passed Keep the parameterized tests. The introduced pure invariants have small, auditable domains or a direct bitmask contract: open_flags has only the two follow_symlinks states, and `is_prohibited_repa…
Unit Architecture ✅ Passed PASS. The change preserves clear boundaries. windows_reparse::open_flags and is_prohibited_reparse_point are pure queries. apply_open_flags makes its mutation explicit through &mut OpenOptions…
Domain Architecture ✅ Passed Keep the change in the existing filesystem adapter boundary. The authoritative diff changes only src/stdlib/path, its Windows-specific filesystem helper, documentation, and integration tests. `src/s…
Observability ✅ Passed PASS — the pull request introduces Windows file-policy behaviour, but it keeps the decision inside the existing file-read boundary. open_file_checked returns the existing InvalidOperation diagnost…
Title check ✅ Passed The title accurately describes the main change and references issue #703, which the description identifies as the issue being closed.
Description check ✅ Passed The description directly explains the Windows same-handle reparse-point fix, its tests, documentation, and issue context.
Full details: User-Facing Documentation

Explanation

Treat the user-facing documentation as incomplete. The pull request changes Windows contents, linecount, hash, and digest behaviour: the default policy now rejects every final-component FILE_ATTRIBUTE_REPARSE_POINT, including junctions, mount points, and non-name-surrogate tags. The review-scoped diff does not change docs/users-guide.md. That guide still documents only final symlinks and says that follow_symlinks=true accepts the link, while the new Windows-specific reparse-point behaviour is documented only in developer and design documents. The changed implementation and ADR explicitly establish this broader policy.

Resolution

Update docs/users-guide.md in the file-reading section. State that, on Windows, the default policy rejects every final-component reparse point, including symlinks, junctions, volume mount points, and other reparse tags, and that only the final component is covered. Explain that follow_symlinks=true opts into following the final link when the target remains within the capability and is a regular file. Add the stricter Windows rejection to the applicable n+1 migration guide if the release treats this behaviour as a breaking change.

Full details: Testing (Compile-Time / Ui)

Explanation

Require a compile-time test. The PR adds the Windows pub(super) const fn functions open_flags and is_prohibited_reparse_point in src/stdlib/path/windows_reparse.rs, which introduces compile-time-capable Rust behaviour. The new tests in src/stdlib/path/windows_reparse_tests.rs call both functions through ordinary runtime locals and loops. The changed files add no trybuild fixture or other compile-time equivalent. The junction and error checks provide useful runtime coverage, and no new structured output requires a snapshot because the assertions target the stable diagnostic meaning.

Resolution

Add a Windows-gated trybuild compile-pass test, or a clear Rust compile-time equivalent, that evaluates open_flags and is_prohibited_reparse_point in const contexts and verifies both policy branches and representative attribute values. Keep the existing runtime handle and integration tests. If a snapshot is added for the diagnostic, redact temporary paths and other variable data.


A guarded handle opens bright
Reparse paths stay out of sight
Junction tests mark the way
Safe reads follow rules each day
Windows checks the handle right

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Windows file reads now open the final component without traversing reparse points, validate attributes from that same handle, and read through it, eliminating the pre-open check race while preserving capability-based access, shared regular-file diagnostics, and the symlink-following opt-in. New Windows tests and ADR/documentation updates cover the behavior and rationale.

Sequence diagram for race-free Windows file validation

sequenceDiagram
    participant Filter
    participant OpenFileChecked
    participant ParentHandle
    participant FileHandle

    Filter->>OpenFileChecked: open_file_checked(path, limits)
    OpenFileChecked->>ParentHandle: open_with(entry, custom_flags)
    Note over ParentHandle: FILE_FLAG_OPEN_REPARSE_POINT
    Note over ParentHandle: FILE_FLAG_BACKUP_SEMANTICS
    ParentHandle-->>OpenFileChecked: FileHandle
    OpenFileChecked->>FileHandle: metadata()
    FileHandle-->>OpenFileChecked: file_attributes()
    alt FILE_ATTRIBUTE_REPARSE_POINT set
        OpenFileChecked-->>Filter: not_regular_file_error(path)
    else ordinary regular file
        Filter->>FileHandle: read bytes
        FileHandle-->>Filter: file contents
    end
Loading

Flow diagram for Windows reparse-point policy modes

flowchart TD
    A["open_file_checked"] --> B["apply_open_flags"]
    B --> C{"follow_symlinks?"}
    C -->|No| D["OPEN_REPARSE_POINT + BACKUP_SEMANTICS"]
    C -->|Yes| E["BACKUP_SEMANTICS"]
    D --> F["open_with"]
    E --> F
    F --> G["metadata from opened handle"]
    G --> H{"Default policy and reparse attribute?"}
    H -->|Yes| I["not_regular_file_error"]
    H -->|No| J["shared is_file check and read"]
Loading

File-Level Changes

Change Details Files
Replace the Windows check-then-open symlink validation with same-handle reparse-point enforcement.
  • Apply FILE_FLAG_OPEN_REPARSE_POINT during the default-policy open.
  • Always apply FILE_FLAG_BACKUP_SEMANTICS so directories reach the shared regular-file rejection.
  • Inspect FILE_ATTRIBUTE_REPARSE_POINT and regular-file status from the handle used for reading.
  • Remove the pre-open symlink_metadata validation and isolate Windows flag/attribute logic in a dedicated module.
src/stdlib/path/fs_utils.rs
src/stdlib/path/mod.rs
src/stdlib/path/windows_reparse.rs
Add Windows regression coverage proving both policy behavior and handle identity.
  • Test junction rejection across contents, linecount, hash, and digest filters.
  • Create junction fixtures with mklink /J and verify they are genuine reparse points.
  • Verify default-policy handles retain the reparse attribute while opt-in handles resolve to the target.
  • Cover flag values and rejection of both surrogate and non-surrogate reparse tags.
tests/std_filter_tests/read_policy_filters/file_type_tests.rs
tests/std_filter_tests/read_policy_filters/mod.rs
tests/std_filter_tests/support.rs
src/stdlib/path/windows_reparse.rs
Document the Windows hardening decision and updated file-reading policy.
  • Record the chosen safe cap_std implementation and rejected raw-FFI alternative in ADR-026.
  • Update developer and design documentation to describe same-handle validation and all-tag reparse rejection.
  • Add ADR-026 to the documentation index.
docs/adr-026-windows-reparse-point-same-handle-open.md
docs/contents.md
docs/developers-guide.md
docs/netsuke-design.md

Assessment against linked issues

Issue Objective Addressed Explanation
#703 Validate the Windows final path component through the same open handle used for reading, using FILE_FLAG_OPEN_REPARSE_POINT and rejecting any prohibited reparse point. ✅
#703 Continue rejecting non-regular files based on metadata from the opened handle while preserving the Unix O_NOFOLLOW path and the shared open_file_checked entry point for all four filters. ✅
#703 Add regression coverage demonstrating the Windows default policy rejects real reparse-point file types, including junctions, without substituting a regular file or silently masking unavailable fixtures. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

codescene-access[bot]

This comment was marked as outdated.

@leynos
leynos force-pushed the issue-703-validate-the-windows-final-component-through-a-same-handle-reparse-point-open branch from 22d0897 to ac3d3e9 Compare September 19, 2026 00:34
codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

leynos pushed a commit that referenced this pull request Sep 19, 2026
Two fixes from the first review round on the new ADR.

CodeRabbit asked for a bare `Accepted.` status and a date-only `Date`. That
was right, and it caught a real error rather than a style preference: the
style guide defines `Date` as the date the ADR was *created*, and this one
was created today, not on 2026-09-17 when the decision was taken. Every
other ADR in the repository uses the bare form, with the decision summary
in its own section, which is where the "deferred, not closed" sentence has
moved. The measurement dates stay in the context section, where they are
read as evidence rather than as the record's identity.

The second fix is a collision. Three open branches claimed ADR 027 --
PR #739's `adr-027-windows-reparse-point-same-handle-open.md`, PR #699's
`adr-027-command-placeholder-contract.md`, and this one. Checking only
`origin/main` for the ceiling was not enough, and the note that triggered
the check recorded that #739 had already taken 027. Per the convention
that the unmerged branch renumbers, this one moves to 028, which no branch
or merged tree holds. The file moves with `git mv`; the heading, the
`[adr-028-trim]` link definition, and all seven inbound references across
the guide, `contents.md`, `nextest.toml` and the test doc comment move
with it.

`make check-fmt` and `make markdownlint` pass, and `.config/nextest.toml`
remains structurally identical to `origin/main` under `tomllib`.
leynos pushed a commit that referenced this pull request Sep 19, 2026
Two fixes from the first review round on the new ADR.

CodeRabbit asked for a bare `Accepted.` status and a date-only `Date`. That
was right, and it caught a real error rather than a style preference: the
style guide defines `Date` as the date the ADR was *created*, and this one
was created today, not on 2026-09-17 when the decision was taken. Every
other ADR in the repository uses the bare form, with the decision summary
in its own section, which is where the "deferred, not closed" sentence has
moved. The measurement dates stay in the context section, where they are
read as evidence rather than as the record's identity.

The second fix is a collision. Three open branches claimed ADR 027 --
PR #739's `adr-027-windows-reparse-point-same-handle-open.md`, PR #699's
`adr-027-command-placeholder-contract.md`, and this one. Checking only
`origin/main` for the ceiling was not enough, and the note that triggered
the check recorded that #739 had already taken 027. Per the convention
that the unmerged branch renumbers, this one moves to 028, which no branch
or merged tree holds. The file moves with `git mv`; the heading, the
`[adr-028-trim]` link definition, and all seven inbound references across
the guide, `contents.md`, `nextest.toml` and the test doc comment move
with it.

`make check-fmt` and `make markdownlint` pass, and `.config/nextest.toml`
remains structurally identical to `origin/main` under `tomllib`.
codescene-access[bot]

This comment was marked as outdated.

@leynos
leynos marked this pull request as ready for review September 19, 2026 16:18

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @leynos, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-19T16:21:53.919316Z 4eee40e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai coderabbitai Bot added the Issue A pull request originating from an issue label Sep 19, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4eee40e77c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/developers-guide.md
@leynos

leynos commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit review — dispositions

Ran coderabbit review --agent --base main on 4eee40e7; 6 findings, all
minor, collapsing to two distinct issues.

Fixed — spelling (tests/std_filter_tests/support.rs:183). CodeRabbit
flagged recognises. Confirmed against the house rule: en-GB-oxendict requires
-ize, and the repository uses recognizes overwhelmingly (31 occurrences,
including four in docs/developers-guide.md). Line changed to recognizes in
6370e3b6. The four other -ise-looking words I added (exercised,
unexercised, raise, otherwise) are correct as spelled.

Declined — skip-on-missing-cmd.exe (support.rs:230 and
windows_reparse_tests.rs:260-266).
CodeRabbit asked that the
Ok(None) branch produce a "real setup error or an explicit test-runner skip"
instead of reporting the case as passed. Declined: the current behaviour is a
faithful copy of the repository's own established convention, which
predates this branch. origin/main already ships, in the same file:

let Some(link) = fallible::file_symlink_fixture(&root)? else {
    skip_without_symlink_support(case);
    return Ok(());
};

with

fn skip_without_symlink_support(case: FilterCase) {
    eprintln!(
        "skipped: {} — this host cannot create a symlink fixture",
        case.name
    );
}

The parallel skip_without_junction_support added here is deliberately
identical in shape so the two skip paths read the same way. No runtime-skip
mechanism exists for plain #[test]/#[rstest] in this repository — the only
rstest_bdd::skip! call site is in tests/bdd/steps/fs.rs, where it is valid
only because it is a BDD step. Changing this would make the junction skip
inconsistent with the symlink skip directly beside it, and would require
inventing a mechanism the repo does not have. The tests do not pass silently
in any case: the skip path writes an explicit skipped: ... line to stderr,
which is asserted to be visible in captured output.

Also declined — the same finding restated three times against different line
ranges.
Same reasoning as above.

codescene-access[bot]

This comment was marked as outdated.

leynos pushed a commit that referenced this pull request Sep 19, 2026
…ides

Three guides still described the pre-open Windows check that this branch
removed, or stated the policy as symlink-only when it in fact refuses every
reparse point.

`users-guide.md` and `stdlib-yaml-and-jinja-guide.md` now say that the Windows
refusal covers junctions, volume mount points, and other tags such as
deduplication or cloud placeholders, and that `follow_symlinks=true` is the
opt-in for all of them. `security-network-command-audit.md` replaces the
"pre-open `symlink_metadata` check on Windows" remediation with
`FILE_FLAG_OPEN_REPARSE_POINT` plus the handle's
`FILE_ATTRIBUTE_REPARSE_POINT`, and records that the judgement and the read
share one handle.

Reported by chatgpt-codex-connector on PR #739.

Co-Authored-By: Claude Code <noreply@anthropic.com>
codescene-access[bot]

This comment was marked as outdated.

@leynos

leynos commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 40150c13. All three premises checked out and all three files are
updated:

  • docs/users-guide.md — the reading-filters section now states that the
    Windows refusal covers every reparse point, not only symlinks: junctions,
    volume mount points, and other tags such as deduplication or cloud
    placeholders, "including tags Windows may add later". It also says
    follow_symlinks=true is the opt-in for those, not just for symlinks.
  • docs/stdlib-yaml-and-jinja-guide.md — the diagnostic comparison no longer
    says "a check made before the open"; it now describes the open declining to
    traverse the reparse point and the refusal coming from the opened handle. The
    same non-symlink reparse coverage and the opt-in note were added.
  • docs/security-network-command-audit.md — the remediation bullet replaces
    "a pre-open symlink_metadata check on Windows" with
    FILE_FLAG_OPEN_REPARSE_POINT, the handle's
    FILE_ATTRIBUTE_REPARSE_POINT refusal, and the fact that the judgement and
    the read share one handle.

One note on scope: the symlink_metadata mention at
docs/netsuke-design.md:1424 was checked and left alone. It describes the
dir/file/symlink test predicates, which inspect an operand's FileType
through Dir::symlink_metadata and do not follow links — a different feature
from the reading filters' open policy, and unaffected by this change.

Verified: make check-fmt, make markdownlint (which includes the typos
gate), and the documentation_examples_tests contract test (31/31) all pass.

codescene-access[bot]

This comment was marked as outdated.

@leynos

leynos commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

ADR renumbered 027 → 032 (87c41ee9, docs-only).

A reviewer opening docs/contents.md will see the new entry jump from 026 to
032, so here is why.

The ADR was originally written as 026, renumbered to 027 when main merged its
own 026, and now moves to 032. The 027 step was itself a collision: PR #699
minted docs/adr-027-command-placeholder-contract.md at 2026-09-18T23:52Z,
31 minutes before this branch's ac3d3e9b landed its own adr-027. Renumbering
off main's ceiling missed it because an ADR number is spent as soon as any
branch mints it, not when it merges
.

A sweep of every remote branch (not just origin/main) shows the range is gone:

Number Held by
026 merged on main
027 #699 — keeps it, claimed first
028 issue-693-trim-the-split-build-dir-harness-test…
029 docs/hexagonal-hardening-and-checking and make-the-build-standard-the-default (two-way collision)
030, 031 docs/hexagonal-hardening-and-checking
032 free — taken here

The commit is a git mv plus its four references updated in lockstep: the ADR's
own H1, the docs/contents.md index entry, and the inbound links in
docs/developers-guide.md and docs/netsuke-design.md. A whole-tree grep for
adr-027-windows / ADR-027 now returns nothing, and the docs/contents.md
link target exists. The documentation style guide specifies a "sequence number"
without a contiguity requirement, and the tree already carries gaps and
duplicate numbers (003, 004, 014, 018) predating this change.

make check-fmt and make markdownlint both pass on 87c41ee9; the full gate
suite and CI are being re-run for the new head.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

leynos added a commit that referenced this pull request Sep 20, 2026
* Record the deferred split-build-dir harness trim (#693)

The 156s figure that motivated trimming
`harness_compiles_under_a_split_build_dir` came from the contended
distribution, where the two isolated-Cargo tests each roughly halved the
other. #687 moved the other one off the Windows lane, so this test got
faster without being touched and the saving a trim could return fell
with it.

Measured across the three runs after #687, the test's exclusive tail is
62.3s, 87.2s and 85.5s, so trimming it is worth about 85s rather than
156s. The lane itself fell from a 1468s median to about 848s across
#687, #690 and #691, which makes that 85s roughly ten percent of what
remains.

Record the decision not to trim it now, the ten-run revisit gate under
which that decision is reconsidered, and the three alternatives already
ruled out with their measured costs: `cargo check` (114s against 102s
cold, and it writes nothing into the target directory so the uplift the
regression exists to catch stops happening), warming the compiler cache
(about three percent), and sharing a target directory (blocked by E0460
races with the `#[once]` fixture).

Describe what a fixture-crate replacement would have to carry, so the
design is on record while the trim is deferred: the fidelity argument
naming the regression it still guards and the coverage it drops, and
the Windows response-file pressure, which a one-dependency fixture would
stop exercising unless it generates enough search paths or the contract
moves to its own dedicated test.

* Point the harness comments at the deferred trim (#693)

Neither `.config/nextest.toml` nor the test's doc comment recorded that
the trim measured at 156s is now worth about 85s, so both still read as
though the larger figure were available.

Add the three post-#687 runs (125.3s, 170.6s and 170.7s against the
274.7s contended median) and the exclusive tails that give the 85s
figure, and point the "candidate for tightening or deletion" note at the
ten-run revisit gate that "Deferring the split-build-dir harness trim"
in docs/developers-guide.md defines.

The test's doc comment gains the fidelity argument — that the subject is
the real `test_support` build rather than a fixture crate, and that the
developers' guide holds the criteria for revisiting that — and the
response-file note, that the long `-L dependency=` set plus the long
temporary roots is what keeps the Windows response-file path exercised,
so any replacement must preserve that pressure or move it to a dedicated
test.

No executable logic and no timeout value changes.

* Address CodeRabbit findings on the trim record (#693)

Two findings, both on the same passage of the nextest comment, and one
prose nit on the guide.

The contention narrative was genuinely ambiguous: "on those same runs"
tied the 125.3s, 170.6s and 170.7s values to the runs where the two
isolated-Cargo tests contended, when they are the three runs *after* the
second build left the Windows lane. Say which direction the change ran —
contention had been increasing each test's elapsed time, and its removal
produced the shorter durations — so the sentence cannot be read as
claiming the values were measured under contention.

The guide's cross-reference to the new fixture-crate subsection used an
inline link too long for mdtablefix to wrap at 80 columns, which
check-fmt rejects. Move it to the file's reference-link convention.

The link label is `fixture-constraints` rather than the longer
`fixture-crate-constraints`: mdtablefix treats `[text][label]` as one
atomic token, and the longer label leaves no valid wrap point, so it
wanted to strand the sentence's full stop on a line of its own. The
visible text, the anchor target, and the convention are unchanged.

Also stop quoting the guide's subsection title inline in the nextest
comment; naming the section rather than reproducing its heading reads
the same and does not invite drift.

* Record the serialization group in the deferred-trim evidence (#693)

Main's nested-cargo-builds test group serializes this test with the other
build-capable child Cargo tests. It landed after the three runs the deferred
trim was sized from, so those samples describe a shape that no longer
exists.

Under the group the test holds the single slot and everything behind it
waits, so a trim returns its whole occupancy rather than its exclusive
tail: 113s to 152s on the three Windows runs after the group landed,
against the 85s the uncontended reading gave. The decision to defer still
stands and the trim remains open at the ten-run gate, now against the
serialized shape.

Recorded in the developers' guide decision record, the nextest.toml budget
comment, and the harness test's doc comment.

* Count the group without a fixed number (#693)

Main added a member to the nested-cargo-builds group while this branch was
open, so the prose count of the other members was already stale by one.
Name the membership instead of counting it, so a later addition to the group
does not silently falsify the sentence.

The group's contract test discovers members rather than listing only the
pinned ones, which is why it stays correct through such an addition.

* Correct the serialized-trim figures against the run logs (#693)

Re-deriving every figure from the three Windows job logs showed three
claims in the record were not what the logs support.

The harness is not the last test to finish in any of the three runs; the
prose said "two of the three". The group's cheap tail members trail it by
under seven seconds in each case.

The saving is not the group occupancy in every run. It is the run's end
less whichever of the trimmed group chain and the last non-group test
finishes later: 152.0s, 149.0s and 113.4s, where only the first two equal
the occupancy and the third is capped because non-group work becomes the
binding constraint once the harness is gone.

The table's middle column is the group chain's end with the trim applied,
not the run's end, so it is now named that way.

All nine figures in the table reproduce from the logs; the changes are to
what the prose claimed about them.

* Record the fourth post-group sample for the harness trim (#693)

The guide stated that trimming `harness_compiles_under_a_split_build_dir`
returned 113s to 152s, from three Windows runs under the
`nested-cargo-builds` group. This pull request's own Windows lane supplies
a fourth: run 35400200137, the harness held the group's slot for 115.7s
and a trim there returns 95.0s, below the low end of the published range.

Add the row, widen the range to 95s to 152s, and correct the per-run
analysis, which claimed the figure was the occupancy exactly in one run
and short of it in one. It is short in the last two: 113s against 125s,
and 95s against 116s, both because unrelated non-group work becomes the
run's next binding constraint once the harness is gone. The four samples
are also not uniform, so say so: the first three are trunk pushes and
this one is a pull-request lane.

The deferral decision is unchanged; only the evidence for it moves. Both
files keep their existing shape, and `.config/nextest.toml` remains a
comment-only change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Anchor the post-group sample instead of counting it (#693)

The post-group write-up counted its own samples: "the four runs", "the
last two", "estimated from five". Every push to this branch starts another
Windows run, so each count was stale by the time the next one landed, and
the fifth run — 35403273264, occupancy 141.6s, trim returns 136.3s — left
three of them wrong.

Anchor the claims rather than re-counting them. The table gains the fifth
run, the range stays 95s to 152s, and the prose now says across-the-sample
and names the date the sample was taken. The "falls short of occupancy"
case is stated as a pattern rather than as an ordinal claim about which
runs did it, since it has now happened in three of the five. The caution
paragraph notes the sample mixes trunk pushes with pull-request lanes and
is a snapshot rather than a running total, so later runs belong to the
revisit gate rather than to this table.

The revisit gate now reads "the sample above" instead of a figure, and the
nextest.toml range carries the same 2026-09-18 date.

The deferral decision is unchanged. `.config/nextest.toml` remains a
comment-only change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Wrap the serialized-value link definition (#693)

The definition line ran to 88 columns. markdownlint's MD013 does not
flag it — it permits an over-length line when nothing follows the last
space before the limit, and this line's only space sits at column 19 —
but the repository already wraps long definitions this way, at
`[github-actions-validation-test]` further up the same file.

Put the fragment on an indented continuation line. The reference still
resolves: every `][serialized-value]` use has a matching definition, and
no definition is left unused.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Anchor the trim figure to its mechanism, not a running range (#693)

The seventh Windows sample returned 162.9s, above the 152s upper bound the
guide had published. The bound was the wrong shape: it read like a
settled range when it was only a running maximum over a sample that keeps
growing, since every push spawns a fresh Windows run.

Replace it with the mechanism. A trim can never return more than the
test's own duration, because the duration *is* the occupancy it gives
back; and it returns exactly that whenever the shortened group chain is
still what bounds the run. It returns less only when unrelated non-group
work binds first. The figure therefore tracks the test's own cost rather
than converging on the 85s exclusive tail, which belonged to an
uncontended lane that no longer exists.

The claim "four of the seven runs above" required the `35405043577` row,
which the table was missing; added, and the count verified programmatically.
Both the guide and the nextest comment are dated to the 2026-09-18 sample
so later runs accrue to the revisit gate rather than falsifying a bound.

* State the tail invariant structurally, not as a 0.1s margin (#693)

The paragraph below the post-group table claimed the group's tail members
trail the harness by "under seven seconds in every run". Re-derived across
all seven rows, the worst gap is 6.9s against that 7.0s bound.

The claim is true, but it is the same shape of hazard that already forced
two rewrites here: a hard numeric bound over a sample that grows by one
run on every push. The invariant underneath it does not decay, because it
follows from the group's structure rather than from the measurement --
tail members cannot start until the harness frees the single slot, so
they necessarily finish after it. State that, and drop the number.

The figures the paragraph exists to support are unchanged; the seven-row
table, its trim-returns column and the mechanism below it were re-verified
against the raw job logs while checking this.

* Separate the group's rationale from the coverage it owes (#693)

CodeRabbit found the paragraph that follows the post-group table claiming
the response-file pressure "makes the group entry necessary in the first
place". That conflates two independent things, and the guide says the
opposite two sections earlier: `nested-cargo-builds` exists because four
nextest workers each starting a four-job child Cargo build on four vCPUs
is what it prevents. Response-file pressure is a coverage requirement a
replacement would owe, not a reason to serialize anything.

A build-capable replacement does not change the contention rationale at
all, so stating them as one requirement would misdirect exactly the
fixture work this section exists to constrain. Split them, and keep the
response-file clause attached to the coverage obligation where the
constraints section already develops it.

* Qualify the doc comment's occupancy claim the way the guide does (#693)

The comment on `harness_compiles_under_a_split_build_dir` said a trim
"would return its whole occupancy rather than only the tail it finishes
on", unconditionally. The guide conditions exactly this figure: the trim
returns the whole occupancy only when the shortened group chain still
bounds the run, and returns less when unrelated work becomes the run's
next binding constraint once the slot frees.

That qualification is not decoration. It is the mechanism the guide was
rewritten around, and the comment stated the stronger, unqualified form
of it next to the measurements that contradict it. The two now agree.

Raised as an unreproduced CodeRabbit finding on an aborted review pass.
It was not corroborated on re-review, but it is correct on its merits, so
it is fixed rather than dismissed on provenance.

* Move the trim decision out of the guide and into ADR-027 (#693)

The decision record was living in `docs/developers-guide.md` as two
subsections. That is the wrong home for it. The style guide reserves the
developer's guide for current responsibilities and points design
rationale and trade-offs at decision records, and it asks the guide to
stay synchronized with them. A deferral with a revisit gate is a decision
with a status, not a description of how the suite works today.

`docs/adr-027-defer-split-build-dir-harness-trim.md` now carries it:
context, the options already measured and rejected, the rule that a trim
can never return more than the test's own duration, the serialized-lane
sample, the ten-run revisit gate, and the consequences. The guide keeps a
short summary of the decision and links to the ADR; it still owns the
constraints a fixture-crate replacement would have to preserve, because
those are implementation requirements rather than decision rationale.

The serialized-measurement table moved rather than being duplicated. Two
copies of a seven-row sample that each push can date further is the drift
this branch already corrected twice, so the ADR is the single owner and
the guide points at it.

`contents.md` gains the ADR-027 entry, and the three inbound references
that named the old section -- in the guide's cross-reference block, in the
Windows override comment in `.config/nextest.toml`, and in the doc comment
on `harness_compiles_under_a_split_build_dir` -- now name the ADR. The
nextest.toml change remains comment-only: verified structurally identical
to `origin/main` under `tomllib`, and no non-comment line appears in its
diff.

Every figure in the ADR was re-derived programmatically from its own
table: seven rows, four returning the full duration, the 115.7s to 162.9s
duration span, the 95s to 163s saving span, the three short rows, and
zero ceiling violations.

Nine gates green on this tree: check-fmt, markdownlint, lint, typecheck,
test (3196 passed, 5 skipped), doc-coverage (98.80%), and nixie.

* Correct the ADR header and renumber it to 028 (#693)

Two fixes from the first review round on the new ADR.

CodeRabbit asked for a bare `Accepted.` status and a date-only `Date`. That
was right, and it caught a real error rather than a style preference: the
style guide defines `Date` as the date the ADR was *created*, and this one
was created today, not on 2026-09-17 when the decision was taken. Every
other ADR in the repository uses the bare form, with the decision summary
in its own section, which is where the "deferred, not closed" sentence has
moved. The measurement dates stay in the context section, where they are
read as evidence rather than as the record's identity.

The second fix is a collision. Three open branches claimed ADR 027 --
PR #739's `adr-027-windows-reparse-point-same-handle-open.md`, PR #699's
`adr-027-command-placeholder-contract.md`, and this one. Checking only
`origin/main` for the ceiling was not enough, and the note that triggered
the check recorded that #739 had already taken 027. Per the convention
that the unmerged branch renumbers, this one moves to 028, which no branch
or merged tree holds. The file moves with `git mv`; the heading, the
`[adr-028-trim]` link definition, and all seven inbound references across
the guide, `contents.md`, `nextest.toml` and the test doc comment move
with it.

`make check-fmt` and `make markdownlint` pass, and `.config/nextest.toml`
remains structurally identical to `origin/main` under `tomllib`.

* Drop the full stop from the ADR's Date value (#693)

The style guide gives the Date field the format `YYYY-MM-DD`, and the
trailing full stop on `2026-09-19.` was mine rather than the convention's.
The three most recent ADRs before this one -- 023, 024 and 026 -- carry
the bare date; 022 and 025 carry the stop, so the repository is mixed and
this is genuinely minor. The spec is unambiguous, so the bare form wins.

`make check-fmt` and `make markdownlint` pass (143 files, 0 errors), with
mdtablefix leaving all 142 files unchanged. The ADR's presence in
markdownlint's scope was confirmed three ways: a count reconciliation
against the config's ignore set, a glob check against its ignores, and a
scoped probe linting the file alone.

The change is a single character on one line.

* Name the third Windows run behind the 170.7s figure (#693)

CodeRabbit flagged an inconsistency: the guide said "the new shape has
three samples" while its table carried only two run columns. The premise
was wrong -- the third sample is real -- but the finding was right that
the set was not traceable, and the fault was mine. My first commit
replaced main's "on those same two runs ... 170.6s" with "125.3s, 170.6s
and 170.7s on the three runs that followed" and never named the third
run. The exclusive tails 62.3s, 87.2s and 85.5s came across from the
measurement work, so the prose was consistent with itself; only the
identifier was missing.

The third run is 34080385050, recovered from the branch that produced the
other two, `measure-windows-isolated-cargo-tests`. It is third in
sequence after 34075197897 and 34079222917 and it reports the harness at
170.741s, which is the 170.7s the comment cites. It is now a table column
rather than a bare number, with its own values measured the same way as
the other two: 6.8s for the packaging test, a 253.8s nextest phase, and a
358s `Test` step.

The extraction was validated before it was trusted. Run against the two
runs whose values were already published, it reproduces the per-test
durations to the decimal and the `Test` step to the second. It also
caught its own first error: a naive phase measurement (`cargo nextest`
invocation to summary) disagreed with both published values, because the
published figure is nextest's own summary line, which excludes build
time. The corrected reading matches to 0.1s on both, which is what
licenses using it on the third.

`nextest.toml` gains the three identifiers for the same reason, so the
comment's 170.7s is traceable without the guide. The guide's lead-in
sentence also said "the first two under the new shape" while enumerating
two runs; it now names all three, which the scrutineer flagged as a
stale clause after the first pass.

`make check-fmt` leaves all 142 files unchanged and `make markdownlint`
reports 0 errors across 143, so the hand-written table padding is
canonical. `.config/nextest.toml` still parses under `tomllib`.

---------

Co-authored-by: leynos <leynos@rohga>
Co-authored-by: Claude Code <noreply@anthropic.com>
codescene-access[bot]

This comment was marked as outdated.

leynos and others added 21 commits September 20, 2026 07:08
Replace the pre-open symlink_metadata check in open_file_checked with an
open that does not traverse a reparse point and a judgement taken from
the handle it returns, so the policy decision and the read share one
handle on Windows as they already do on Unix through O_NOFOLLOW.

The new windows_reparse submodule sets FILE_FLAG_OPEN_REPARSE_POINT via
cap_std's Windows-only OpenOptionsExt::custom_flags and refuses an opened
handle carrying FILE_ATTRIBUTE_REPARSE_POINT. Testing the attribute bit
rather than the tag also rejects junctions and volume mount points, which
std does not report as symlinks. No unsafe, no new dependency, and no
lint relaxation is required.

Record the decision in ADR-026 and update the design and developers
guides, which described reject_windows_symlink and its known race.

Refs #703
The prohibition was justified by a claim that junctions are not name
surrogates. They are: `IO_REPARSE_TAG_MOUNT_POINT` is `0xA000_0003`, so bit
29 is set, and `std` reports a junction as a symlink. The same holds for a
volume mount point.

The claim mattered because it was doing load-bearing work in the argument
for testing the attribute bit. That argument survives, but on the real gap:
a tag that is *not* a name surrogate — a deduplication or cloud placeholder
— is missed by `FileType::is_symlink` and, worse, is reported by `std` as
`is_file() == true`, so the shared regular-file check would accept it. That
is what makes `reject_reparse_point` load-bearing rather than redundant with
the check beside it.

No change in behaviour; the policy already tested the attribute bit. The
prose now states the reason the code is written that way.

Refs #703
The policy was only exercised through `open_flags` and the three ABI
constants. Nothing asserted the property the policy actually turns on: that
testing the attribute bit refuses the tags a name-surrogate test would miss.

Extract `is_prohibited_reparse_point` as a pure predicate and pin it against
real tag values — symlink and mount point for the surrogate case, NFS,
deduplication and cloud for the non-surrogate case — cross-checking each
fixture's own bit 29 so the table cannot drift from what it claims to test.
A plain file's attributes must not be refused.

Add a Windows integration test that creates a real directory junction with
`mklink /J` and asserts all four filters refuse it. A junction needs no
privilege, so unlike the symlink fixture there is nothing to skip on an
ordinary host: the fixture reports `Ok(None)` only when `cmd.exe` is absent,
and every other failure propagates rather than passing green over an
unexercised policy. `require_real_junction` re-reads the reparse attribute
without following the link, so a fixture that degraded to a plain directory
cannot invert the assertions.

The junction is refused by `reject_reparse_point` and, independently, by the
shared regular-file check, since `std` reports a junction as a symlink.
Driving both would need a non-surrogate reparse point, which no unprivileged
fixture can create; the unit test above covers that gap instead.

Refs #703
`make check-fmt` runs `mdtablefix --wrap` over every tracked Markdown file,
and the hand-wrapped paragraphs in the three touched documents did not match
its line breaking. Rewrapped in place with the same flags the Makefile passes.

The reflow is cosmetic: a token-stream comparison of each file before and
after shows the two sequences identical, so no wording changed.

Refs #703
The documentation style guide requires sentence case for headings, and the
ADR template spells the sections that way: "Decision drivers", "Decision
outcome", "Known risks and limitations", "Architectural rationale". ADR-026
was the only ADR in the tree that used title case for all four.

The date keeps its trailing period. The same template writes the field as
`YYYY-MM-DD.`, and ADR-025 and ADR-022 follow it, so the formatting review
suggestion to drop the period conflicts with the documented convention and is
not applied.

No behavioural change; headings only, and nothing links to these anchors.

Refs #703
Add a handle-level test that only passes if FILE_FLAG_OPEN_REPARSE_POINT
reaches the open. The integration test cannot detect that: std reports a
junction as a symlink, so metadata.is_file() refuses the resolved directory
regardless, and the policy would look correct while the flag went unapplied.
Asserting the property on the opened handle's attributes is the only place
the distinction shows, so a dropped flag now fails a test instead of
silently widening the policy.

The fixture is extracted into a helper, which also takes the test from 69 to
41 counted lines against the 70-line threshold: it had a single line of
headroom on a lane only Windows CI compiles, so any small future edit would
have broken that job with no local signal.

Narrow the OpenOptionsExt import in fs_utils back to #[cfg(unix)]. Its only
call site is inside #[cfg(unix)] apply_unix_open_flags; the Windows arm
reaches custom_flags through windows_reparse, which has its own import.
Commit 77c99e7 had widened it to any(unix, windows), which leaves the import
live but unused on Windows and fails lint-clippy under -D warnings. Linux
gates cannot see this, because the file's Windows code is cfg-gated.

Both findings come from a local cross-compile probe: the main crate cannot be
built for x86_64-pc-windows-msvc here because ring needs MSVC lib.exe, so the
probe mirrors the module tree with file modules (Clippy resets its nesting
counter at a file module and counts it through an inline one), copies
windows_reparse byte for byte, and compiles the Windows-gated integration
fixtures that the Linux gates never reach.

Refs #703
main gained adr-026-manifest-environment-access-policy.md (PR #666) while
this branch was in review, so the Windows reparse-point ADR moves to 027.
Also drops the trailing full stop from the Date line, matching the two most
recent ADRs on main (024, 026), which write the bare YYYY-MM-DD.
The unit-test junction fixture spawned `cmd.exe` unconditionally and
`expect`ed the spawn to succeed, so a host without `cmd.exe` failed the
test instead of reporting that its subject was unavailable. That is both
a false negative for a valid Windows host and an inconsistency with the
sibling integration fixture, `fallible::junction_fixture` in
tests/std_filter_tests/support.rs, which maps `ErrorKind::NotFound` to a
skip.

Map that one error kind to `None` from `junction_fixture`, and return
early from the caller with a skip notice on captured test output. Every
other spawn error and a non-zero `mklink` exit still assert: a junction
needs no privilege, so a missing `cmd.exe` is the only unavailability
that is a skip, and a real fault must not be masked as one.

The skip is printed rather than silent so a green run cannot quietly
hide the regression on a host that can build a junction; the
`#[expect(clippy::print_stderr, ...)]` is fulfilled by that branch.
…ossible assertion

The Windows CI lane failed three ways, all in the reparse-point module. This
addresses each, and adds a local oracle that reproduces the lane in seconds.

`module_must_have_inner_docs` wanted a `//!` line as the test module's first
item. Adding one pushed the module past `module_max_lines`' 400-line limit, so
the tests move to a `windows_reparse_tests.rs` sibling, reached through
`#[path]`, as `status_tests.rs` already does.

`no_expect_outside_tests` rejected five `expect` calls in `junction_fixture`.
The lint exempts `#[test]` functions, but a plain helper is production code to
it however it is gated, and `#[cfg(test)]` on the enclosing module does not
register: rustc strips `cfg` attributes before the HIR the lint inspects. The
fixture now returns `Result` and propagates, with `ensure!` rather than
`assert!` because the repo denies `panic_in_result_fn`.

The opt-in half of the junction test asserted something impossible. `mklink /J`
records an absolute target, and `cap_std`'s resolver refuses to follow a
reparse point whose destination leaves the capability, reporting
`escape_attempt()` as `PermissionDenied`; a capability open cannot traverse a
junction under either policy. That half is dropped, and the security-critical
half — the default policy's handle *is* the reparse point — is kept and
strengthened with an ordinary-directory control, so the refusal is shown to be
about the link rather than about directories generally. The handle is taken
ambiently because it must be.

ADR-027 gains the capability-limitation risk and a Verification section that
says which layer carries which property, since `std` reports a junction as a
symlink and the integration test alone cannot see the flag.

Verified with `cargo dylint --target x86_64-pc-windows-msvc` against the probe
crate, which reproduces the Windows lint verdict exactly, plus clippy on the
same target. A deliberate syntax error in the test file fails that run, so the
oracle is genuinely compiling the Windows-gated code.
The `-ize` form is the en-GB-oxendict preference this repository
enforces elsewhere, and the surrounding prose already uses it. No
behavioural change: the edit is inside a doc comment.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ides

Three guides still described the pre-open Windows check that this branch
removed, or stated the policy as symlink-only when it in fact refuses every
reparse point.

`users-guide.md` and `stdlib-yaml-and-jinja-guide.md` now say that the Windows
refusal covers junctions, volume mount points, and other tags such as
deduplication or cloud placeholders, and that `follow_symlinks=true` is the
opt-in for all of them. `security-network-command-audit.md` replaces the
"pre-open `symlink_metadata` check on Windows" remediation with
`FILE_FLAG_OPEN_REPARSE_POINT` plus the handle's
`FILE_ATTRIBUTE_REPARSE_POINT`, and records that the judgement and the read
share one handle.

Reported by chatgpt-codex-connector on PR #739.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The earlier renumber to 027 was itself a collision. PR #699 minted
docs/adr-027-command-placeholder-contract.md at 2026-09-18T23:52Z, 31 minutes
before this branch's ac3d3e9 landed its own 027. Renumbering off main's
ceiling (026) missed it because an ADR number is spent as soon as any branch
mints it, not when it merges.

A sweep of every remote branch shows 028, 029, 030, and 031 are taken too —
029 by two branches independently:

  026  merged on main
  027  PR #699
  028  issue-693-trim-the-split-build-dir-harness-test...
  029  docs/hexagonal-hardening-and-checking and make-the-build-standard-the-default
  030  docs/hexagonal-hardening-and-checking
  031  docs/hexagonal-hardening-and-checking
  032  free

Take 032 and leave 027 to #699, which claimed it first. Update the four
references in lockstep: the ADR's own H1, the docs/contents.md index entry,
and the two inbound links in developers-guide.md and netsuke-design.md. No
other file mentions the number.
CodeRabbit raised one concern three times: that `follow_symlinks=true`
does not override capability containment, so the docs must not imply it
does. The premise is right, but the mechanism it named — "an absolute
target that leaves the capability" — is wrong, and writing it down would
have misdescribed what an operator can rely on.

The resolver never compares the resolved path against the capability
root. It refuses a link destination containing a prefix or root component
outright, so it cannot tell an absolute target that stays inside from one
that does not: both are refused. The trigger is the target's
*absoluteness*, not its escaping.

Measured rather than inferred. Two symlinks pointing at the same file
inside one capability, one written relatively and one absolutely, under
the opt-in policy: the relative link opened, the absolute link was
refused with "a path led outside of the filesystem". Same file, same
containment, different result — only the spelling differed.

Documented at all four sites that state the policy, since a partial
correction would leave the wrong mechanism standing somewhere:
`users-guide.md`, `stdlib-yaml-and-jinja-guide.md`, ADR-032's known
risks, and the Windows unit test whose doc comment explains why the
junction handle is taken ambiently.

The ADR records the superseded wording explicitly rather than quietly
replacing it, so a reader who met the earlier draft can see which claim
was wrong and why.

Verified: the junction fixture's target is a relative `"file"`, so the
opt-in integration test that ADR-032 cites as covering the follow path
does in fact use the supported spelling.

Refs #703.
`make check-fmt` failed on `docs/users-guide.md` in both the Linux
`build-test` job and `Windows / lint-windows` for 2ffd72d: mdtablefix
wanted +2 -2 on exactly one line.

The line was 80 characters. Hand-wrapping prose to fit the 80-column
budget is not the same as satisfying mdtablefix's own wrap, which chose a
different break point. The previous commit hand-wrapped; this one runs
the formatter over the file instead, which is the only way to get the
same answer the gate does.

Only the wrap point moved — no wording changed, and no other paragraph
was touched. Verified with the gate's own invocation:

    mdtablefix --check --git --include-untracked --wrap --renumber \
      --breaks --ellipsis --fences
    -> 142 files left unchanged, exit 0

Refs #703.
ADR-032's Verification section described two Windows-only tests as
carrying the guarantee, which reads as though continuous integration had
confirmed them. It has not: the Windows test lane halts on the unrelated
pre-existing failure tracked as issue 743 before nextest reaches
`stdlib::path`.

Measured from the job log rather than assumed: the run ends at 1078/2901
tests, and the strings `windows_reparse` and `junction` appear zero times
in the whole log. A decision record that overstates its own evidence is
worse than one that names the gap, so the gap is now named — what is
verified (compiles, lint-clean, tests compile, on a local
`x86_64-pc-windows-msvc` probe) and what is not (that the tests pass on
the platform they govern), with the two-tool reason the lint evidence
needs both `cargo dylint` and `cargo clippy`.

Docs-only; no behaviour change. Formatted with mdtablefix rather than by
hand, so `mdtablefix --check` reports 142 files unchanged, exit 0.

Refs #703.
The evidence section pointed readers at the local probe crate as the main
route to Windows verification, which sold the CI evidence short. The
`Windows / lint-windows` lane runs `cargo clippy --workspace --all-targets
--all-features -- -D warnings`, and `--all-targets` compiles the library's
`cfg(test)` module and the integration test targets. Since
`windows_reparse_tests.rs` is a `#[cfg(test)]` child of the lib, every
Windows-gated line — the module, its tests, and the junction fixture — is
compiled on Windows itself under `-D warnings`, on the platform's own
toolchain rather than an approximation of it. That job is green on this head.

The probe crate is still worth recording, but as what it is: the local
development loop, needed because the main crate cannot be cross-compiled
here (`ring` needs MSVC's `lib.exe`), not the guarantee.

The limit is narrowed rather than removed. Compiling a test under `-D
warnings` does not run it, and the lane that would run it still stops 1800
tests short of `stdlib::path`.

Co-Authored-By: Claude Code <noreply@anthropic.com>
My previous commit hand-wrapped a paragraph in this file and `check-fmt`
failed in `build-test`: `docs/adr-032-...md +2 -2`, `1 file would be
reformatted`, `make: *** [Makefile:314: check-fmt] Error 1`. The lines were
within 80 columns; the break position was wrong. That is the trap recorded in
the formatter memory, and I walked into it a second time in this same file.

Fixed by running the tool rather than the ruler:

  mdtablefix --in-place --wrap --renumber --breaks --ellipsis --fences \
    docs/adr-032-windows-reparse-point-same-handle-open.md

only the break between "-- all-features" and "Whitaker's" moved. Verified with
the invocation the gate itself uses,
`mdtablefix --check --git --include-untracked --wrap --renumber --breaks
--ellipsis --fences` -> `142 files left unchanged`, exit 0.

Markdown-only; no code or behaviour touched.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`1078/2901 (1077 passed, 1 failed, 2 skipped)` is the one claim in this record
most likely to age — it moves the moment issue 743 is fixed and a green
Windows lane starts running these tests. Cite it against commit `2d8e5305`
and name the test it died on, so a later reader can tell a stale number from a
contradicted one.

Re-measured on `2d8e5305` rather than carried over from the earlier run: same
figures, and `windows_reparse`/`junction` still appear zero times in the job
log.

Verified with `mdtablefix --check --git --include-untracked --wrap --renumber
--breaks --ellipsis --fences` -> `142 files left unchanged`, exit 0, and the
file was formatted with the tool rather than by hand.

Co-Authored-By: Claude Code <noreply@anthropic.com>
CodeRabbit finding 2 was the one finding in its batch with a valid basis: this
guide sat at 397 lines on `main` and my earlier prose took it to 405. The
other four findings cite a file-length rule that `AGENTS.md:31` scopes to
"code file" and that only pylint enforces (`pyproject.toml:175`,
`max-module-lines`), against documents already 2000-7300 lines on `main`; those
are not this PR's to fix and are not defects.

The cap claim is still worth honouring where this branch caused the crossing, so
the added prose is trimmed from 12 inserted lines to 5. The dropped material
enumerated reparse tags and restated the `follow_symlinks` containment
behaviour, both of which `users-guide.md#configure-file-reading-limits` already
carries in full — and this guide already cross-linked to that manual once, at
line 387, so the pattern is established. Sibling guides updated alongside cover
the security audit rather than the user-facing policy.

Result: 400 lines, no longer over the threshold.

Verified with `mdtablefix --check --git --include-untracked --wrap --renumber
--breaks --ellipsis --fences` -> `142 files left unchanged`, exit 0; the file
was formatted with the tool.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The Windows-gated tests only run on a Windows host, and the Windows test lane
has been known to stop short of `stdlib::path` (ADR-032 records the figure), so
the policy's two load-bearing branches had no evidence that ran on every push.
A `const` assertion does not depend on a test lane reaching the module: rustc
evaluates it whenever the module is compiled, and `Windows / lint-windows`
compiles every Windows-gated line through `cargo clippy --all-targets`, which
is green on every head of this branch.

Both `pub(super)` functions are now pinned in `const` contexts:
`open_flags(false)` must set `FILE_FLAG_OPEN_REPARSE_POINT`, `open_flags(true)`
must not, either policy must keep `FILE_FLAG_BACKUP_SEMANTICS` so a directory
can still be opened and rejected by the shared regular-file check, and
`is_prohibited_reparse_point` must accept an attribute value carrying
`FILE_ATTRIBUTE_REPARSE_POINT` and refuse an ordinary one. No production
behaviour changes, no dependency is added, no function's visibility changes,
and the runtime tests are untouched — they still cover the handle behaviour and
the diagnostics that a compile-time assertion cannot see.

The oracle was shown live rather than assumed. Against a probe crate mirroring
the module tree, the true code compiles clean (exit 0); dropping the flag from
the default branch aborts the build with `E0080: evaluation panicked: the
default policy must not traverse a reparse point` and exit 101; and a broken
attribute test fails independently on its own assertion, so neither branch is
merely riding on its neighbour's assertion.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two documents described the `follow_symlinks` opt-in in symlink-only terms
while on Windows it governs the final component's reparse point as a whole,
which is the finding CodeRabbit raised on `docs/stdlib-yaml-and-jinja-guide.md`
line 178 and `docs/security-network-command-audit.md` line 153. The premise is
sound even though the wording of the finding inverted the mechanism: neither
file mentions a pre-open metadata check, and `users-guide.md` already carries
the requested content in full. Fixing the narrower defect the premise points at:

  - guide: `permits the final component to be a symlink` ->
    `waives that final-component refusal`; net-zero on line count (400).
  - audit: the same correction, and the sentence is reflowed rather than
    patch-edited, so the clause order stays readable (163 lines).
  - ADR-032: `## Verification` now records three layers — the compile-time
    assertion added in `5d2dab68`, the unit test, and the integration test —
    and `### How far this evidence actually extends` is rewritten, because the
    section it replaces concluded that none of these tests had ever run in CI.
    That was true when it was written and issue 743 is now fixed on `main`.

The rewritten section also corrects a claim of mine that was wrong, not merely
stale: I had argued that zero occurrences of the fixtures' skip line proved the
junction was built, because nextest captures a passing test's stderr. It does
not. A probe crate shows the marker is absent from nextest output under the
default profile, and the `success-output = "immediate"` entries in
`.config/nextest.toml` cover three unrelated test groups with no
`--success-output` in CI. The argument now rests on the fixtures' single narrow
quiet arm (`ErrorKind::NotFound` from spawning `cmd`, every other outcome
failing) and on `require_real_junction` checking the reparse attribute before
use, with the unmeasured premise — that the runner image can spawn `cmd.exe` —
stated as such. The record of the error is left in place rather than tidied
away.

Docs only; no code, test, or build configuration changes. Applied with
`mdtablefix --in-place` and verified as a no-op against the hand-written
wording, so the result is formatter-canonical rather than hand-wrapped.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@leynos
leynos force-pushed the issue-703-validate-the-windows-final-component-through-a-same-handle-reparse-point-open branch from d4accd7 to 108fad1 Compare September 20, 2026 05:32
@buzzybee-df12

Copy link
Copy Markdown
Collaborator

@coderabbitai approve

@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Comments resolved and changes approved.

@leynos
leynos merged commit c7cf148 into main Sep 20, 2026
21 checks passed
@leynos
leynos deleted the issue-703-validate-the-windows-final-component-through-a-same-handle-reparse-point-open branch September 20, 2026 05:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Issue A pull request originating from an issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate the Windows final component through a same-handle reparse-point open

3 participants