Skip to content

Refine the path-containment primitive's API shape now that it has real callers #1962

Description

@phmatray

Problem / motivation

#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 / PathEscapeReasonboth 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.

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: emit-coreCross-target / shared emitter infrastructure (Koine.Emit.Common)effort: SSmall — ~hours to ~1 dayenhancementNew feature or requestpriority: lowTier 3 — differentiator / polish

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions