Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
297bad0
Harden the Windows final component through a same-handle reparse open
Sep 18, 2026
b1490be
Correct the reparse-tag rationale in the design documents
Sep 18, 2026
9facc58
Test the reparse policy against real tag values and add a junction test
Sep 18, 2026
3bc4cbc
Reflow the ADR-026 prose to the mdtablefix canonical form
Sep 18, 2026
d241138
Use sentence case for the ADR-026 section headings
Sep 18, 2026
a99b9bc
Test the reparse policy on the handle and narrow a Windows-only import
Sep 18, 2026
704aa78
Renumber ADR-026 to ADR-027 after main claimed 026
Sep 19, 2026
30e7c34
test(windows): skip the junction fixture when cmd.exe is absent
Sep 19, 2026
6b0ad8b
fix(windows): satisfy the Windows-only Whitaker lints and drop an imp…
Sep 19, 2026
4c3ef48
docs(tests): spell "recognizes" in the junction fixture comment
Sep 19, 2026
2cb3ef4
docs: describe the Windows reparse-point policy in the user-facing gu…
Sep 19, 2026
d8aeaae
Renumber ADR-027 to ADR-032 after three later claims
Sep 19, 2026
cbec7e5
Correct the follow_symlinks containment mechanism
Sep 19, 2026
6c3f44c
Rewrap the users-guide paragraph mdtablefix's way
Sep 19, 2026
c755c1a
State the ADR's evidence limit plainly
Sep 19, 2026
3862bf7
docs(adr-032): credit native Windows CI for compiling the gated code
Sep 19, 2026
306f1d2
style(adr-032): let mdtablefix choose the wrap point
Sep 19, 2026
241b528
docs(adr-032): pin the test-lane figure to its commit
Sep 19, 2026
7b4599b
docs(stdlib guide): trim the reparse detail to a cross-link
Sep 19, 2026
5232908
test(windows): assert the reparse policy at compile time
Sep 20, 2026
108fad1
docs: correct the opt-in wording and the ADR evidence record
Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 349 additions & 0 deletions docs/adr-032-windows-reparse-point-same-handle-open.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ operator, user, and contributor references are easier to find.
- [ADR-028](adr-028-defer-split-build-dir-harness-trim.md): Deferred trim of
the split-build-dir harness test, with the serialized-lane measurements that
made the figure unstable and the ten-run gate that reopens the question.
- [ADR-032](adr-032-windows-reparse-point-same-handle-open.md): Windows final
component validation through a same-handle reparse-point open.

## Proposals

Expand Down
32 changes: 24 additions & 8 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -5911,18 +5911,34 @@ and the `fcntl_getfl`/`fcntl_setfl` calls come from `rustix::fs::OFlags` and
`rustix::fs`, a production dependency (1.0.8, `fs` feature, per `Cargo.toml`)
used for this Unix flag handling. Once the opened handle is confirmed to be a
regular file, `restore_blocking` clears `O_NONBLOCK`. The regular-file check
runs on the opened handle, so devices and FIFOs are rejected race-free. Windows
has no `O_NOFOLLOW` through cap-std, so `reject_windows_symlink` checks
`symlink_metadata` before the open; that check is not race-free and is tracked
as issue #703.
runs on the opened handle, so devices and FIFOs are rejected race-free.

Windows has no `O_NOFOLLOW` through cap-std, but `windows_reparse` reaches the
same guarantee through `cap_std`'s Windows-only `OpenOptionsExt::custom_flags`,
which is OR-ed into the `dwFlagsAndAttributes` argument of the open.
`apply_open_flags` sets `FILE_FLAG_OPEN_REPARSE_POINT` while symlinks are not
followed, so the open does not traverse a reparse point and the returned handle
refers to the point itself, and it always sets `FILE_FLAG_BACKUP_SEMANTICS` so
a directory can be opened and then rejected by the shared regular-file check
rather than by the open failing. The decision is then taken from that same
handle: `reject_reparse_point` reads `file_attributes()` — populated from
`BY_HANDLE_FILE_INFORMATION` on the open handle — and refuses anything carrying
`FILE_ATTRIBUTE_REPARSE_POINT`. Testing the attribute bit rather than the tag
rejects every reparse point, including tags `std` does not report as symlinks:
`FileType::is_symlink` is true only for name-surrogate tags, so a deduplication
or cloud placeholder would slip past a symlink-shaped check and be followed.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Because the judgement and the read share one handle, there is no
check-then-open window between them; see
[ADR-032](adr-032-windows-reparse-point-same-handle-open.md).

Two diagnostics come out of the boundary. `bounded_read.rs` raises
`file_too_large_error`, which quotes the path and the limit that was exceeded;
`fs_utils.rs` raises `not_regular_file_error`, which quotes only the path and
is what rejects an opened FIFO or device (and a Windows symlink). On Unix a
symlink refused by `O_NOFOLLOW` instead surfaces through the mapped open error.
All of them, like the invalid-UTF-8 diagnostic that `contents` and `linecount`
raise for undecodable input, are MiniJinja `InvalidOperation` errors. See
is what rejects an opened FIFO or device (and, on Windows, a reparse point that
`reject_reparse_point` refuses). On Unix a symlink refused by `O_NOFOLLOW`
instead surfaces through the mapped open error. All of them, like the
invalid-UTF-8 diagnostic that `contents` and `linecount` raise for undecodable
input, are MiniJinja `InvalidOperation` errors. See
[Digest rendering](#digest-rendering) for the hashing loop that consumes this
boundary.

Expand Down
23 changes: 13 additions & 10 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1515,18 +1515,21 @@ Implementation notes:
final component. Blocking mode is restored once the opened object is
confirmed to be a regular file, and the regular-file check runs on the opened
handle, so special files are rejected without a check-then-open window. On
Windows cap-std exposes no `O_NOFOLLOW`, so the pre-open `symlink_metadata`
check is the platform's best available guard; it is not race-free, and that
residual risk is tracked separately (issue #703).
Windows the default policy asks the open itself not to traverse a reparse
point and then refuses the opened handle when it carries
`FILE_ATTRIBUTE_REPARSE_POINT`; that refuses symlinks, junctions, volume
mount points, and every other tag alike, because the test is on the attribute
bit rather than on the tag value. The judgement and the read share one
handle. See [ADR-032](adr-032-windows-reparse-point-same-handle-open.md).
- An over-budget read fails with `stdlib.path.contents.file_too_large`, which
quotes the path and the byte limit. An opened object that is not a regular
file (a FIFO, device, or a Windows symlink refused ahead of the open) fails
with `stdlib.path.contents.not_regular_file`, which quotes the path alone. A
Unix symlink refused by `O_NOFOLLOW` surfaces as the mapped open error for
the action (the `stdlib.path.io.failed` family) instead of the regular-file
diagnostic, because the refusal happens while opening. `linecount` validates
UTF-8 incrementally as it counts, so a file that is not text is rejected
rather than silently counted as opaque bytes.
file (a FIFO, a device, or a Windows reparse point refused on the opened
handle) fails with `stdlib.path.contents.not_regular_file`, which quotes the
path alone. A Unix symlink refused by `O_NOFOLLOW` surfaces as the mapped
open error for the action (the `stdlib.path.io.failed` family) instead of the
regular-file diagnostic, because the refusal happens while opening.
`linecount` validates UTF-8 incrementally as it counts, so a file that is not
text is rejected rather than silently counted as opaque bytes.
- Each of the four filter closures records its call through
`src/stdlib/path/read_telemetry.rs`: one sample of the bounded counter
`netsuke_stdlib_file_read_total`, labelled `filter` (`contents`, `linecount`,
Expand Down
16 changes: 11 additions & 5 deletions docs/security-network-command-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,21 @@ introduces, and concrete remediation tasks that would harden the helpers.
- **Remediation:** the reading filters now share one policy. The final path
component is opened without following symlinks (`O_NOFOLLOW` on Unix,
where the open is also non-blocking so a FIFO or device cannot wedge a
build worker; a pre-open `symlink_metadata` check on Windows), and the
opened handle must be a regular file. `contents`, `linecount`, `hash`, and
build worker; `FILE_FLAG_OPEN_REPARSE_POINT` on Windows, so the open
returns the reparse point itself instead of traversing it), and the
opened handle must be a regular file. On Windows the handle is also
refused when it carries `FILE_ATTRIBUTE_REPARSE_POINT`, which rejects
junctions, volume mount points, and every other reparse tag rather than
only those `std` reports as symlinks. Because the judgement and the read
share one handle, there is no check-then-open window between them.
`contents`, `linecount`, `hash`, and
`digest` stream against a running byte total anchored to
`StdlibConfig::with_file_max_read_bytes` (default 8 MiB). `linecount`
counts terminators incrementally instead of materializing the file.
Per-call `max_bytes` may narrow the ceiling and a named
`follow_symlinks=true` opt-in permits link following; budget rejections
name the path and the applicable limit, file-type rejections name only the
path, and neither discloses file contents.
`follow_symlinks=true` opt-in waives that final-component refusal; budget
rejections name the path and the applicable limit, and file-type
rejections name only the path; neither discloses file contents.

## Next steps

Expand Down
15 changes: 9 additions & 6 deletions docs/stdlib-yaml-and-jinja-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,18 +166,21 @@ non-blocking, so a FIFO cannot wedge the render worker first. A symlink final
component is refused on both platforms, but not with the same diagnostic: on
Unix the default open declines to follow it, so the failure comes from the open
itself and names the path together with the platform's symbolic-link detail,
while on Windows a check made before the open reuses the not-a-regular-file
diagnostic. Two optional keyword arguments narrow a call without touching the
operator ceiling:
while on Windows the open declines to traverse the reparse point, so the
refusal comes from the opened handle and reuses the not-a-regular-file
diagnostic. That Windows refusal covers every reparse tag, not only symlinks,
and a relative-target link is the supported opt-in case. Two optional keyword
arguments narrow a call:

- `max_bytes` lowers the budget for one call (a value above the configured
budget is clamped to it). Example:
`{{ 'fixtures/big.bin' | contents(max_bytes=1024) }}`.
- `follow_symlinks=true` permits the final component to be a symlink. Example:
- `follow_symlinks=true` waives that final-component refusal. Example:
`{{ 'link/version.txt' | contents(follow_symlinks=true) }}`.

See the users' guide section on file reading limits for the defaults, the
symlink policy, and the trust model these limits assume.
See
[Configure file reading limits](users-guide.md#configure-file-reading-limits)
for the defaults, the full policy, and the trust model.

MD5 and SHA-1 are available only in builds compiled with Cargo feature
`legacy-digests`. Without that feature, `hash('md5')`, `hash('sha1')`, and their
Expand Down
17 changes: 15 additions & 2 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1838,8 +1838,21 @@ library.
The reading filters also refuse to follow a symlink as the final path component
and reject anything that is not a regular file once opened, including FIFOs and
device nodes. A symlinked directory used *inside* a path is unaffected; only
the final entry is checked. Templates that deliberately read through a final
symlink can pass `follow_symlinks=true` to accept the link:
the final entry is checked. On Windows the same refusal covers every reparse
point, not only symlinks: junctions, volume mount points, and other tags such
as deduplication or cloud placeholders are all rejected, including tags Windows
may add later.

`follow_symlinks=true` waives that final-component refusal, and only that. The
capability that anchors every read in the workspace is unaffected, so a link
whose target is written as an absolute path is still refused with a diagnostic
reporting that a path led outside the filesystem — even when the target is in
fact inside the workspace. Relativity of the *link target*, not containment of
the resolved path, is what the resolver tests. A template that reads through a
relative-target link is the supported case; on Windows a junction cannot be
one, because `mklink` records an absolute target and so is always refused under
either policy. Templates that deliberately read through a final symlink can
pass the opt-in to accept it:

<!-- tested-example: guide-file-follow-symlinks-expression -->

Expand Down
63 changes: 25 additions & 38 deletions src/stdlib/path/fs_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use rustix::fs::OFlags;
use crate::localization::{self, keys};

use super::path_utils::normalise_parent;
#[cfg(windows)]
use super::windows_reparse;
use crate::stdlib::io_helpers::io_to_error;

/// An ambient handle to a path's parent directory and the entry name within it.
Expand Down Expand Up @@ -53,20 +55,25 @@ pub(crate) fn not_regular_file_error(path: &Utf8Path) -> Error {

/// Open `path` for reading under the file-reading safety policy.
///
/// On Unix the open is non-blocking, so a FIFO or device final component
/// The default policy opens the final path component without following a link
/// on either platform, and the decision is taken from the handle the caller
/// then reads: Unix asks for that in the open itself with `O_NOFOLLOW`, and
/// Windows asks the open not to traverse a reparse point and then judges the
/// returned handle's attributes (see `windows_reparse`). Neither platform
/// consults a separate path lookup, so a concurrent replace of the final
/// component cannot make the decision and the read disagree.
///
/// On Unix the open is also non-blocking, so a FIFO or device final component
/// cannot wedge the render worker inside `open` even when the caller opted
/// into following symlinks; blocking mode is restored once the opened object
/// is confirmed to be a regular file. The final path component is opened
/// without following symlinks unless `limits.follow_symlinks` opts in, and the
/// opened object must be a regular file, checked on the opened handle so
/// devices and FIFOs are rejected race-free.
/// is confirmed to be a regular file.
///
/// # Errors
///
/// Returns a template error when the parent directory cannot be opened, the
/// target cannot be opened, the final component is a symlink while following
/// is disabled, the opened object is not a regular file, or blocking mode
/// cannot be restored.
/// target cannot be opened, the final component is a link while following is
/// disabled, the opened object is not a regular file, or blocking mode cannot
/// be restored.
pub(crate) fn open_file_checked(path: &Utf8Path, limits: &FileReadLimits) -> Result<File, Error> {
let parent = open_parent_dir(path)?;
let mut options = OpenOptions::new();
Expand All @@ -76,10 +83,11 @@ pub(crate) fn open_file_checked(path: &Utf8Path, limits: &FileReadLimits) -> Res
// default policy, which rejects a symlink final component.
#[cfg(unix)]
apply_unix_open_flags(&mut options, limits.follow_symlinks, path)?;
// On Windows directory opens must stay permitted under both policies so
// the shared regular-file check below reports the documented rejection
// rather than the open failing.
#[cfg(windows)]
if !limits.follow_symlinks {
reject_windows_symlink(&parent, path)?;
}
windows_reparse::apply_open_flags(&mut options, limits.follow_symlinks);
let file = parent
.handle
.open_with(Utf8Path::new(&parent.entry), &options)
Expand All @@ -97,6 +105,12 @@ pub(crate) fn open_file_checked(path: &Utf8Path, limits: &FileReadLimits) -> Res
err,
)
})?;
// Judged from the handle just opened, never from the path, so the policy
// decision and the read cannot diverge.
#[cfg(windows)]
if !limits.follow_symlinks {
windows_reparse::reject_reparse_point(&metadata, path)?;
}
if !metadata.is_file() {
return Err(not_regular_file_error(path));
}
Expand Down Expand Up @@ -135,33 +149,6 @@ fn apply_unix_open_flags(
Ok(())
}

/// Reject a symlink final component ahead of an open on Windows.
///
/// Windows exposes no `O_NOFOLLOW` through cap-std, so the pre-open
/// `symlink_metadata` check is the platform's best available guard.
///
/// # Errors
///
/// Returns a template error when the metadata cannot be read or names a
/// symlink.
#[cfg(windows)]
fn reject_windows_symlink(parent: &ParentDir, path: &Utf8Path) -> Result<(), Error> {
let metadata = parent
.handle
.symlink_metadata(Utf8Path::new(&parent.entry))
.map_err(|err| {
io_to_error(
path,
&localization::message(keys::STDLIB_PATH_ACTION_STAT),
err,
)
})?;
if metadata.file_type().is_symlink() {
return Err(not_regular_file_error(path));
}
Ok(())
}

/// Clear `O_NONBLOCK` from `file` after a non-blocking policy open.
///
/// # Errors
Expand Down
2 changes: 2 additions & 0 deletions src/stdlib/path/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ mod fs_utils;
mod hash_utils;
mod path_utils;
mod read_telemetry;
#[cfg(windows)]
mod windows_reparse;

#[cfg(test)]
mod home_metrics_tests;
Expand Down
101 changes: 101 additions & 0 deletions src/stdlib/path/windows_reparse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Windows final-component policy: open the reparse point itself and judge the
//! handle that the read will use.
//!
//! Unix reaches this guarantee inside `open` with `O_NOFOLLOW`, so the policy
//! decision and the read share one call. Windows has no flag of that name, but
//! `FILE_FLAG_OPEN_REPARSE_POINT` has the same effect: the handle that comes
//! back refers to the reparse point rather than to whatever it points at. The
//! judgement then reads that handle's own attributes, so nothing between the
//! decision and the read can redirect either one — a concurrent rename in the
//! containing directory changes which entry the path names, but it cannot
//! change what an already-open handle refers to.
//!
//! The policy rejects **every** reparse point, not only the ones `std` reports
//! as symlinks. `FileType::is_symlink` is a test on the tag *value*: it is true
//! only when the tag is a name surrogate (bit 29 set), which covers symlinks,
//! junctions, and volume mount points, but is false for every other tag — a
//! deduplication or cloud placeholder, for example. An open that follows one of
//! those would hand back the target's handle while a symlink-shaped check saw
//! nothing to refuse. Testing the attribute bit instead asks "is this a reparse
//! point at all", which is the policy the callers actually want and needs no
//! knowledge of which tags a future Windows release may mint.
use camino::Utf8Path;
use cap_std::fs::MetadataExt as _;
use cap_std::fs_utf8::{Metadata, OpenOptions, OpenOptionsExt};
use minijinja::Error;

use super::fs_utils::not_regular_file_error;

/// `FILE_FLAG_OPEN_REPARSE_POINT`: open the reparse point instead of following
/// it. The flag is ignored when the entry is not a reparse point, so it costs
/// an ordinary file nothing.
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;

/// `FILE_FLAG_BACKUP_SEMANTICS`: permit a directory to be opened at all.
///
/// Without it an open of a directory fails outright, and the caller would
/// report an open error where Unix reports the regular-file diagnostic. The
/// flag exists to let the shared `is_file` check reject a directory on its
/// attributes, exactly as it does on Unix.
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;

/// `FILE_ATTRIBUTE_REPARSE_POINT`: the entry carries a reparse tag.
///
/// This is the attribute the policy consumes; the tag value itself is
/// deliberately never inspected, so a tag this build has never heard of is
/// refused rather than waved through.
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;

/// The `dwFlagsAndAttributes` bits the policy passes to the open.
///
/// `FILE_FLAG_BACKUP_SEMANTICS` is unconditional, so a directory can be opened
/// and then rejected by the shared regular-file check rather than by the open
/// failing. `FILE_FLAG_OPEN_REPARSE_POINT` is added only by the default policy,
/// which must not traverse a reparse point.
pub(super) const fn open_flags(follow_symlinks: bool) -> u32 {
let mut flags = FILE_FLAG_BACKUP_SEMANTICS;
if !follow_symlinks {
flags |= FILE_FLAG_OPEN_REPARSE_POINT;
}
flags
}

/// Apply the Windows half of the open policy to `options`.
///
/// Mirrors `apply_unix_open_flags`: the default policy asks the open itself not
/// to follow a reparse point, and the opt-in policy leaves the open to resolve
/// the link as usual.
pub(super) fn apply_open_flags(options: &mut OpenOptions, follow_symlinks: bool) {
options.custom_flags(open_flags(follow_symlinks));
}

/// Whether `file_attributes` describes a reparse point the policy refuses.
///
/// The test is on the attribute bit alone, and deliberately never on the tag
/// value. A tag-value test would have to enumerate acceptable tags, and a
/// name-surrogate test — the shape `FileType::is_symlink` uses — misses every
/// tag that is not a name surrogate, such as a deduplication or cloud
/// placeholder.
pub(super) const fn is_prohibited_reparse_point(file_attributes: u32) -> bool {
file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
}

/// Reject an opened handle whose entry is a reparse point.
///
/// `metadata` must come from the handle the caller will read, never from a
/// separate path lookup: that is what makes the refusal race-free.
///
/// # Errors
///
/// Returns the non-regular-file diagnostic when the handle carries the
/// reparse-point attribute.
pub(super) fn reject_reparse_point(metadata: &Metadata, path: &Utf8Path) -> Result<(), Error> {
if is_prohibited_reparse_point(metadata.file_attributes()) {
return Err(not_regular_file_error(path));
}
Ok(())
}

#[cfg(test)]
#[path = "windows_reparse_tests.rs"]
mod tests;
Loading
Loading