Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 118 additions & 12 deletions src/backends/wslc/common/src/policy_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,11 @@ impl NormalizedPath {
/// Excluding exact match here is deliberate: an exact same-*string* deny==mount
/// is already collapsed most-restrictive-wins at parse time
/// (`normalize_filesystem_paths`), and an exact same-*object* alias (a deny
/// that canonicalizes onto the mount root) is collapsed by D6
/// (`normalize_object_conflicts`) before this validator runs. This check
/// therefore only owns the *strictly-nested* overlap that neither of those
/// layers can resolve (deny under a mount = distinct objects).
/// that canonicalizes onto the mount root) is normally collapsed by D6
/// (`normalize_object_conflicts`) before this validator runs. This method
/// therefore only owns the *strictly-nested* overlap; the Tier-2 caller
/// separately rejects alias-induced canonical equality (differing original
/// strings) that D6 missed as absent.
fn contains_strictly(&self, child: &NormalizedPath) -> bool {
if self.rooted && self.components.is_empty() {
// Whole-drive mount: covers the entire drive, so any same-anchor path
Expand Down Expand Up @@ -252,13 +253,13 @@ impl NormalizedPath {
/// `C:\secrets` through `C:\project\link` if the runtime follows the reparse
/// point. Detecting this would require walking the mounted subtree for reparse
/// points (expensive and still racy) or controlling traversal beneath the mount,
/// which the WSLC SDK does not expose. Likewise, Tier 2 does not fold Unicode
/// normalization forms and compares path endpoints, not object identity: a hard
/// link inside a mounted tree pointing at a denied file resolves to its own
/// in-tree name (not the denied one), so it is not caught here or by D6 (the two
/// are distinct objects). Creating either alias requires write access to a
/// location the guest does not have, so both are out of scope under the
/// trusted-author threat model. A residual TOCTOU window also remains between
/// which the WSLC SDK does not expose. The same gap applies to a hard link: since
/// hard links are names for one object, hard-linked *policy entries* are caught by
/// D6's file-ID grouping (`normalize_object_conflicts`); only an *unlisted* hard
/// link inside a mounted subtree escapes both Tier 2 and D6. Creating either alias
/// needs write access the guest lacks, so both are out of scope under the
/// trusted-author threat model. Tier 2 also does not fold Unicode normalization
/// forms. A residual TOCTOU window remains between
/// canonicalization and the SDK mount (an alias could be swapped in between);
/// fully closing it needs handle-based mounting the WSLC SDK does not expose, so
/// it is likewise accepted.
Expand Down Expand Up @@ -354,11 +355,29 @@ fn validate_denied_path_overlap_with(
continue; // Absent: no on-disk ancestor resolved, so nothing to alias.
};
let denied_norm = NormalizedPath::parse(denied_resolved);
let denied_lexical = NormalizedPath::parse(denied);
for (mounted, list_name, mount_canon) in &mount_canon {
let PathCanonical::Canonical(mount_resolved) = mount_canon else {
continue;
};
if NormalizedPath::parse(mount_resolved).contains_strictly(&denied_norm) {
let mount_norm = NormalizedPath::parse(mount_resolved);
// Strict nesting is always an unenforceable overlap: the denied
// subtree lives inside a flat mount that WSLC cannot mask.
if mount_norm.contains_strictly(&denied_norm) {
return Err(overlap_error(denied, mounted, list_name, true));
}
// Exact canonical equality is an overlap only when it comes from an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The rationale comment on the new equality guard credits the wrong layer, which makes a load-bearing invariant easy to break.

// Exact canonical equality is an overlap only when it comes from an
// on-disk alias (the two original strings differ lexically). A
// literal/case/separator/`..`-variant deny == mount is the
// enforceable exact-match case (collapsed most-restrictive-wins at
// parse time), so accept it.
let mount_lexical = NormalizedPath::parse(mounted);
if mount_norm == denied_norm && mount_lexical != denied_lexical {

Two mismatches with the code:

  1. "the two original strings differ lexically" — the guard doesn't compare original strings. It compares NormalizedPath::parse(mounted) against NormalizedPath::parse(denied), i.e. lexically normalized forms. That distinction is doing all the work here.
  2. "collapsed most-restrictive-wins at parse time"normalize_filesystem_paths (config_parser.rs:378-412) collapses via HashSet<String> + denied.contains(p), which is byte-identical string equality. It does not fold case, separators, or ... So C:\Project vs c:/project sails straight through parse time; what actually accepts it is NormalizedPath::parse right here (policy_mapping.rs:138-174to_lowercase(), split(['/', '\\']), and ./.. folding).

Worth noting the pre-existing comment on contains_strictly just above (:185-191) states this correctly and narrowly:

an exact same-string deny==mount is already collapsed most-restrictive-wins at parse time (normalize_filesystem_paths)

That's accurate — normalize_filesystem_paths genuinely does handle the same-string case. The new comment reuses that same parenthetical but widens the claim to "literal/case/separator/..-variant", which is the part that isn't true of that layer. So this is a small accuracy regression against a neighbouring comment that already had it right.

Why it matters beyond wording. The behavior is correct today; the risk is the next editor. Someone reading "original strings differ lexically" could reasonably tighten this to the literal reading

if mount_norm == denied_norm && mounted != denied {

which would then reject C:\Project (mount) vs c:/project (deny) as alias-induced overlap — turning an enforceable exact-match config into a hard validation failure. Someone reading "collapsed at parse time" could equally conclude the NormalizedPath::parse calls here are redundant and drop them, with the same outcome. Neither change would fail a test today (see below).

Suggested rewrite:

// Exact canonical equality is an overlap only when it comes from an on-disk
// alias — i.e. the two spellings normalize to *different lexical* forms yet
// resolve to the same object. A case/separator/`..` variant of the same
// spelling folds to an equal `NormalizedPath` here and is the enforceable
// exact-match case (enforced by simply not mounting the path), so accept it.
// That fold is done by `NormalizedPath::parse` below, NOT by
// `normalize_filesystem_paths`, which only dedupes byte-identical strings.
// But a deny that aliases onto the mount root via a symlink, junction, or
// short name targets the mounted object with no exclusion primitive —
// `contains_strictly` (strictly-deeper only) misses that, so reject it here.

One edge case the imprecise wording hides, worth confirming is intended: the comment implies every "literal variant" is accepted, but parse doesn't fold the extended-length prefix. \\?\C:\project yields { drive: None, rooted: true, components: ["?", "c:", "project"] } while C:\project yields { drive: Some("c:"), rooted: true, components: ["project"] } — lexically different. Since canonicalize_path does collapse \\?\ prefixes (filesystem_canonical.rs:24-26), a deny written as \\?\C:\project against a mount of C:\project is canonical-equal + lexically-different, so it gets rejected as an alias — even though it's a pure spelling variant with no on-disk indirection. Failing closed is the safe direction, so this isn't urgent, but the user-facing "cannot be enforced" error would be misleading. Either fold the prefix in parse or narrow the comment's claimed accept-set.

Finally, the accept side isn't pinned by any test — the only acceptance case uses byte-identical strings, so the normalization fold this comment describes is entirely untested. Worth adding C:\Project vs c:/project and C:\project vs C:\x\..\project, both with canonical-equal resolver results, asserting the config is accepted.

// on-disk alias — the two spellings normalize to *different lexical*
// forms yet resolve to the same object. A case/separator/`..` variant
// of the same spelling folds to an equal `NormalizedPath` here (via
// `NormalizedPath::parse`, NOT `normalize_filesystem_paths`, which only
// dedupes byte-identical strings) and is the enforceable exact-match
// case (enforced by not mounting the path), so accept it. But a deny
// that aliases onto the mount root via a symlink, junction, or short
// name has no exclusion primitive — `contains_strictly` (strictly
// deeper only) misses that, so reject it here.
let mount_lexical = NormalizedPath::parse(mounted);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new discriminator is only ever exercised in one direction, so the regression that would silently break it isn't detectable by the suite.

let mount_lexical = NormalizedPath::parse(mounted);
if mount_norm == denied_norm && mount_lexical != denied_lexical {

The reject side is well covered — canonical_rejects_denied_alias_resolving_into_mount, canonical_rejects_mounted_alias_containing_denied, canonical_rejects_absent_leaf_resolving_under_aliased_mount, and the new canonical_rejects_denied_alias_equal_to_mount_root all drive mount_lexical != denied_lexical true. The accept side has exactly one test, canonical_allows_literal_exact_match_that_resolves (:797-811), and it uses byte-identical strings (C:\project vs C:\project).

That leaves a specific mutation undetected:

if mount_norm == denied_norm && mounted != denied {   // raw &String compare

The entire suite still passes. The only accept-case uses identical strings, for which a raw comparison and a NormalizedPath comparison agree. In production it would now reject C:\Project (mount) vs c:/project (deny) as alias-induced overlap, turning an enforceable config into a hard "cannot be enforced" failure. (This is the concrete cost of the comment imprecision I raised separately on :369-378.)

Deleting the clause outright is caught by the existing test, so the hole is specifically lexically-different-but-normalization-equal spellings — exactly the set NormalizedPath::parse exists to fold.

Suggested tests — note resolver returns Absent for unmapped paths, so both spellings need explicit mappings:

#[test]
fn canonical_allows_case_and_separator_variant_exact_match() {
    // `NormalizedPath::parse` case-folds and folds `/` vs `\`, so these are the
    // same spelling, not an on-disk alias: enforceable by not mounting → accept.
    let resolve = resolver(vec![
        (r"C:\Project", canonical(r"C:\project")),
        ("c:/project", canonical(r"C:\project")),
    ]);
    validate_denied_path_overlap_with(
        &strings(&[r"C:\Project"]),
        &[],
        &strings(&["c:/project"]),
        resolve,
    )
    .expect("case/separator variant of the same path must be accepted");
}

#[test]
fn canonical_allows_dot_dot_variant_exact_match() {
    // `C:\x\..\project` folds to `C:\project` lexically — same spelling, not an alias.
    let resolve = resolver(vec![
        (r"C:\project", canonical(r"C:\project")),
        (r"C:\x\..\project", canonical(r"C:\project")),
    ]);
    validate_denied_path_overlap_with(
        &strings(&[r"C:\project"]),
        &[],
        &strings(&[r"C:\x\..\project"]),
        resolve,
    )
    .expect("`..`-variant of the same path must be accepted");
}

They slot in right after :811. A trailing-separator case (C:\project\ vs C:\project) is a cheap third — parse filters empty segments, so it should also be accepted.

if mount_norm == denied_norm && mount_lexical != denied_lexical {
return Err(overlap_error(denied, mounted, list_name, true));
}
}
Expand Down Expand Up @@ -755,6 +774,93 @@ mod tests {
assert!(err.contains("cannot be enforced"), "{err}");
}

#[test]
fn canonical_rejects_denied_alias_equal_to_mount_root() {
// Absent-tail deny whose parent alias lands *exactly* on the mount root:
// D6 saw the deny as absent, but tail replay canonicalizes it onto the
// mount root. `contains_strictly` (strictly deeper only) misses this equal
// case, so Tier 2's explicit canonical-equality check must reject it.
let resolve = resolver(vec![
(r"C:\real", canonical(r"C:\real")),
(r"C:\link\missing\..", canonical(r"C:\real")),
]);
let err = validate_denied_path_overlap_with(
&strings(&[r"C:\real"]),
&[],
&strings(&[r"C:\link\missing\.."]),
resolve,
)
.unwrap_err();

assert!(err.contains("cannot be enforced"), "{err}");
}

#[test]
fn canonical_allows_literal_exact_match_that_resolves() {
// A literal deny == mount (same original string) that both canonicalize to
// an existing object must still be accepted: parse-time normalization
// collapses it most-restrictive-wins, and it is enforceable by simply not
// mounting the path. Only alias-induced equality (differing originals) is
// rejected, so an identical string must survive Tier 2.
let resolve = resolver(vec![(r"C:\project", canonical(r"C:\project"))]);
validate_denied_path_overlap_with(
&strings(&[r"C:\project"]),
&[],
&strings(&[r"C:\project"]),
resolve,
)
.expect("literal exact-match deny is enforceable and must be accepted");
}

#[test]
fn canonical_allows_case_and_separator_variant_exact_match() {
// `NormalizedPath::parse` case-folds and folds `/` vs `\`, so these are the
// same spelling, not an on-disk alias: enforceable by not mounting → accept.
let resolve = resolver(vec![
(r"C:\Project", canonical(r"C:\project")),
("c:/project", canonical(r"C:\project")),
]);
validate_denied_path_overlap_with(
&strings(&[r"C:\Project"]),
&[],
&strings(&["c:/project"]),
resolve,
)
.expect("case/separator variant of the same path must be accepted");
}

#[test]
fn canonical_allows_dot_dot_variant_exact_match() {
// `C:\x\..\project` folds to `C:\project` lexically — same spelling, not an alias.
let resolve = resolver(vec![
(r"C:\project", canonical(r"C:\project")),
(r"C:\x\..\project", canonical(r"C:\project")),
]);
validate_denied_path_overlap_with(
&strings(&[r"C:\project"]),
&[],
&strings(&[r"C:\x\..\project"]),
resolve,
)
.expect("`..`-variant of the same path must be accepted");
}

#[test]
fn canonical_allows_trailing_separator_variant_exact_match() {
// `parse` drops empty segments, so a trailing separator is the same spelling.
let resolve = resolver(vec![
(r"C:\project", canonical(r"C:\project")),
(r"C:\project\", canonical(r"C:\project")),
]);
validate_denied_path_overlap_with(
&strings(&[r"C:\project"]),
&[],
&strings(&[r"C:\project\"]),
resolve,
)
.expect("trailing-separator variant of the same path must be accepted");
}

#[test]
fn empty_mounts_never_fail_closed() {
// With no mounts nothing can overlap, so an unresolvable deny must NOT
Expand Down
148 changes: 140 additions & 8 deletions src/core/wxc_common/src/filesystem_canonical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ pub fn canonicalize_path(path: &str) -> PathCanonical {
CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, HANDLE,
};
use windows::Win32::Storage::FileSystem::{
CreateFileW, GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, FILE_NAME_NORMALIZED,
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
GETFINALPATHNAMEBYHANDLE_FLAGS, OPEN_EXISTING, VOLUME_NAME_DOS,
CreateFileW, GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES,
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GETFINALPATHNAMEBYHANDLE_FLAGS,
OPEN_EXISTING, VOLUME_NAME_DOS,
};

// RAII guard so the handle is closed on every exit path, including an
Expand Down Expand Up @@ -83,12 +84,53 @@ pub fn canonicalize_path(path: &str) -> PathCanonical {
// the error captured by the failed call itself (no second, racy
// GetLastError round-trip).
let code = e.code();
return if code == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0)
|| code == HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0)
if code != HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0)
&& code != HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0)
{
PathCanonical::Absent
} else {
PathCanonical::Unknown
// Exists but unexaminable (access denied, I/O error, …).
return PathCanonical::Unknown;
}
// "Not found" from the open above is ambiguous: that open *follows*
// reparse points, so a dangling symlink/junction (the link object
// exists but its target is missing) also reports FILE/PATH_NOT_FOUND.
// Re-probe without following the reparse point; if the link itself
// opens, the path exists but cannot be resolved — classify it Unknown
// so denied-path callers fail closed rather than replay a stale alias.
// SAFETY: `wide` is a local NUL-terminated buffer; all other pointers
// are NULL.
let reparse = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
FILE_READ_ATTRIBUTES.0,
share,
None,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
None,
)
};
return match reparse {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The reparse re-probe fails open on any outcome that isn't a clean success or a true "not found", and the downstream effect is worse than a skipped check — it manufactures a confidently wrong canonical path.

The wildcard arm collapses every non-Ok(valid) result into Absent:

return match reparse {
    Ok(h) if !h.is_invalid() => { let _guard = OwnedHandle(h); PathCanonical::Unknown }
    // Nothing here at all (or a genuinely missing parent): absent.
    _ => PathCanonical::Absent,
};

_ also absorbs Err(ERROR_ACCESS_DENIED) (a DACL on the link object denying FILE_READ_ATTRIBUTES), Err(ERROR_SHARING_VIOLATION) (a holder with FILE_SHARE_NONE), filter/I-O errors, and Ok(invalid_handle). In each of those the link object may well exist — we simply couldn't examine it.

That directly contradicts the documented contract on the type being returned (filesystem_canonical.rs:27-31):

/// Cleanly missing — no object exists, so there is nothing to alias.
Absent,
/// Present (or maybe present) but unresolvable: access denied, I/O error, or
/// an unsupported build target. Callers fail closed on this when denies apply.
Unknown,

"Access denied" is enumerated under Unknown by name. It's also inconsistent with this function's own first CreateFileW branch 30 lines above, which correctly routes every non-NOT_FOUND code to Unknown.

Why this is a deny bypass rather than a conservative skip. The consumer is canonicalize_allowing_absent_tail (filesystem_canonical.rs:135), whose ancestor walk (:153-173) treats Absent as "keep climbing" and only bails out on Unknown (:172). Take a deny of C:\link\file where C:\link is a junction into a mounted tree whose target is missing and whose link object can't be opened:

  1. canonicalize_path("C:\link\file") → follow-open hits the dangling junction → NOT_FOUND → probe → Absent.
  2. Walk to C:\link. Follow-open → NOT_FOUND. Probe fails ACCESS_DENIEDAbsent (should be Unknown).
  3. Because it's Absent, the walk continues to C:\, which resolves Canonical.
  4. The tail is replayed onto it (:158-170), returning Canonical("C:\link\file").

So the validator doesn't skip anything — it receives a fully-confident canonical path that still contains the unresolved junction, compares that literal spelling against the mount list, finds no overlap, and accepts the config. The junction's actual target inside the mounted subtree is never considered. Note Absent from the whole function only happens when no ancestor resolves; since the drive root essentially always resolves, the replay path above is the common case, not the corner case.

This is precisely the outcome the comment on the probe is written to prevent — "classify it Unknown so denied-path callers fail closed rather than replay a stale alias." The wildcard defeats that intent in exactly the situation it describes.

Fix — only a genuine not-found is Absent; everything else is Unknown:

return match reparse {
    // The link object itself exists: present but unresolvable → fail closed.
    Ok(h) if !h.is_invalid() => {
        let _guard = OwnedHandle(h);
        PathCanonical::Unknown
    }
    // Success with an invalid handle: no error to read, so unexaminable.
    Ok(_) => PathCanonical::Unknown,
    Err(e) => {
        let code = e.code();
        if code == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0)
            || code == HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0)
        {
            // Nothing here at all (or a genuinely missing parent): absent.
            PathCanonical::Absent
        } else {
            // Present but unexaminable (access denied, sharing violation, I/O) → fail closed.
            PathCanonical::Unknown
        }
    }
};

Once that's in, canonicalize_allowing_absent_tail:172 propagates the Unknown and the walk correctly stops at the junction.

For coverage, the higher-value regression test is at the canonicalize_allowing_absent_tail level rather than on canonicalize_path alone — a dangling junction ancestor with an inaccessible link object must not yield Canonical(<path containing the junction>). That exercises the whole chain, which is where the security property actually lives. If the ACL fixture proves awkward against real Win32, factoring the HRESULT + reparse-existence → PathCanonical classification behind a small injectable seam (like the resolve closure already used in policy_mapping.rs) would make all four arms unit-testable without touching disk.

// The link object itself exists: present but unresolvable →
// fail closed. Drop the probe handle immediately via the guard.
Ok(h) if !h.is_invalid() => {
let _guard = OwnedHandle(h);
PathCanonical::Unknown
}
// Success with an invalid handle: no error to read → unexaminable.
Ok(_) => PathCanonical::Unknown,
Err(e) => {
let code = e.code();
if code == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0)
|| code == HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0)
{
// Genuinely missing (or a missing parent): absent.
PathCanonical::Absent
} else {
// Present but unexaminable (access denied, sharing
// violation, I/O) → fail closed.
PathCanonical::Unknown
}
}
};
}
// CreateFileW reported success but returned an invalid handle: there is
Expand Down Expand Up @@ -354,6 +396,96 @@ mod tests {
);
}

#[cfg(windows)]
#[test]
fn dangling_reparse_point_is_unknown_not_absent() {
// A junction whose target is missing exists as an object but makes the
// *followed* open report FILE/PATH_NOT_FOUND. It must classify Unknown
// (present-but-unresolvable → fail closed), never Absent, so denied-path
// callers do not treat it as cleanly-missing and replay a stale alias.
use std::process::Command;

// Thread id keeps concurrent tests from colliding on the fixture dir.
let tmp = std::env::temp_dir();
let base = format!(
r"{}\mxc-dangling-{}-{:?}",
tmp.to_string_lossy().trim_end_matches('\\'),
std::process::id(),
std::thread::current().id()
);
let link = format!(r"{base}\link");
let missing_target = format!(r"{base}\no-such-target");
let _ = std::fs::remove_dir_all(&base); // clear any leftover from an aborted run
std::fs::create_dir_all(&base).unwrap();

// `mklink /J` creates a junction even when the target does not exist.
let status = Command::new("cmd")
.args(["/c", "mklink", "/J", &link, &missing_target])
.status()
.unwrap();
// mklink /J needs no elevation, so a failure is a broken environment,
// not an expected skip — surface it rather than silently no-op.
assert!(
status.success(),
"mklink /J failed ({status}); junction creation needs no elevation, so this \
indicates a broken test environment rather than an expected skip"
);

let result = canonicalize_path(&link);
// Remove the junction itself (do not follow it) before clearing base.
let _ = std::fs::remove_dir(&link);
let _ = std::fs::remove_dir_all(&base);

assert_eq!(
result,
PathCanonical::Unknown,
"a dangling junction must be Unknown (fail closed), not Absent"
);
}

#[cfg(windows)]
#[test]
fn dangling_junction_ancestor_is_unknown_not_canonical() {
// Chain-level guard where the security property lives: a deny under a
// dangling-junction *ancestor* must fail closed, never replay the absent
// tail onto a resolved grandparent and return Canonical(<path with the
// junction>) — which the overlap check would then miss.
use std::process::Command;

let tmp = std::env::temp_dir();
let base = format!(
r"{}\mxc-dangling-anc-{}-{:?}",
tmp.to_string_lossy().trim_end_matches('\\'),
std::process::id(),
std::thread::current().id()
);
let link = format!(r"{base}\link");
let missing_target = format!(r"{base}\no-such-target");
let leaf = format!(r"{link}\file");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();

let status = Command::new("cmd")
.args(["/c", "mklink", "/J", &link, &missing_target])
.status()
.unwrap();
assert!(
status.success(),
"mklink /J failed ({status}); junction creation needs no elevation"
);

let result = canonicalize_allowing_absent_tail(&leaf);
let _ = std::fs::remove_dir(&link);
let _ = std::fs::remove_dir_all(&base);

assert_eq!(
result,
PathCanonical::Unknown,
"a deny under a dangling-junction ancestor must fail closed, not \
resolve to a Canonical path still containing the junction"
);
}

#[cfg(not(windows))]
#[test]
fn non_windows_is_unknown() {
Expand Down
Loading