You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#1942 landed contained_path (Rust host) and ContainedPath.TryResolve (.NET) in PR #1949, then immediately grew two callers — SafeArchiveExtractor and the Tauri host's resolve_in. Writing those callers surfaced three places where the primitive's shape makes a caller work harder than it should. None is a correctness or security gap; all three are ergonomics, which is why they were deliberately left out of #1949 rather than widening its blast radius.
1. SymlinkEscape collapses three distinct failures. The variant fires for "a symlink resolved outside the root", "the root itself could not be canonicalized" and "an ancestor could not be stat'd" (the fail-closed path added during #1949's review). A caller that wants to report why cannot: SafeArchiveExtractor discards the reason entirely (out _) and substitutes its own, because passing the primitive's through would say "symlink escape" for a root that simply doesn't exist. A distinct Unresolvable variant would let a caller distinguish "hostile input" from "the environment is not what you assumed".
2. The root must already exist.TryResolve fails closed when the root cannot be canonicalized, which is right — but it forces a create-then-resolve ordering on any caller whose job includes creating the destination. SafeArchiveExtractor has to create the destination root before it can resolve its first member, and then collapse "couldn't create the root" and "couldn't canonicalize it" into one result.
3. No already-canonical-root variant. Every call re-canonicalizes the full root chain. For a 4096-member archive under a deep root that is O(members × depth) lstats — correct, but not cheap. A caller that has already resolved its root once (every archive extraction, by construction) has no way to say so.
Proposed solution
Taken together these are one change to the primitive's surface, not three:
Add an Unresolvable variant to PathEscape / PathEscapeReason — both hosts, plus PublicAPI.Unshipped.txt and the shared corpus (tests/fixtures/path-containment/cases.json), which is what keeps the two implementations honest.
Offer an overload taking an already-canonicalized root, with the canonicalization contract stated as the caller's obligation. That answers 2 and 3 at once: the caller canonicalizes the root when it creates it, then resolves each member against it.
Keep the existing signature as the safe default — a caller that doesn't opt in should not be able to get this wrong.
The corpus README already documents that root-side conditions can't be expressed per-candidate; an Unresolvable variant may make some of them expressible, which is worth checking while doing this.
Fix only the performance point (3). The O(members × depth) walk is the only item with a measurable cost, but the overload that fixes it is the same overload that fixes 2, so splitting them saves nothing.
Area
Emit-core / shared infrastructure
Related:#1942 (introduced the primitive), PR #1949 (where these were found and deferred), #1937 / #1938 / #1941 (the callers still to come)
Problem / motivation
#1942 landed
contained_path(Rust host) andContainedPath.TryResolve(.NET) in PR #1949, then immediately grew two callers —SafeArchiveExtractorand the Tauri host'sresolve_in. Writing those callers surfaced three places where the primitive's shape makes a caller work harder than it should. None is a correctness or security gap; all three are ergonomics, which is why they were deliberately left out of #1949 rather than widening its blast radius.1.
SymlinkEscapecollapses three distinct failures. The variant fires for "a symlink resolved outside the root", "the root itself could not be canonicalized" and "an ancestor could not be stat'd" (the fail-closed path added during #1949's review). A caller that wants to report why cannot:SafeArchiveExtractordiscards the reason entirely (out _) and substitutes its own, because passing the primitive's through would say "symlink escape" for a root that simply doesn't exist. A distinctUnresolvablevariant would let a caller distinguish "hostile input" from "the environment is not what you assumed".2. The root must already exist.
TryResolvefails closed when the root cannot be canonicalized, which is right — but it forces a create-then-resolve ordering on any caller whose job includes creating the destination.SafeArchiveExtractorhas to create the destination root before it can resolve its first member, and then collapse "couldn't create the root" and "couldn't canonicalize it" into one result.3. No already-canonical-root variant. Every call re-canonicalizes the full root chain. For a 4096-member archive under a deep root that is O(members × depth)
lstats — correct, but not cheap. A caller that has already resolved its root once (every archive extraction, by construction) has no way to say so.Proposed solution
Taken together these are one change to the primitive's surface, not three:
Unresolvablevariant toPathEscape/PathEscapeReason— both hosts, plusPublicAPI.Unshipped.txtand the shared corpus (tests/fixtures/path-containment/cases.json), which is what keeps the two implementations honest.The corpus README already documents that root-side conditions can't be expressed per-candidate; an
Unresolvablevariant may make some of them expressible, which is worth checking while doing this.Alternatives considered
Area
Emit-core / shared infrastructure
Related: #1942 (introduced the primitive), PR #1949 (where these were found and deferred), #1937 / #1938 / #1941 (the callers still to come)