Three engineers reviewing Python path containment checks and comparing uploads and uploads-archive folders on dual monitors.

A Shared Path Prefix Does Not Keep a File Inside Your Directory

Mon, Oct 26, 2026

A pathname can look as though it begins under an approved directory and still identify a different file. The simplest controlled example uses two owned sibling directories named uploads and uploads-archive: a character-prefix comparison says the second path begins with the first string, while resolving the pathname identifies a file outside the trusted root. Python explicitly documents that os.path.commonprefix() compares character by character and warns against treating that algorithm as a secure pathname-prefix test.

That failure is only the first layer of the review. commonpath() reasons in path components rather than arbitrary characters. Path.is_relative_to() answers a lexical ancestry question without consulting the filesystem. Path.resolve() actually follows symbolic links and handles .. according to filesystem resolution. None of those statements alone proves that a later read will reach the same file if the namespace can change.

This playbook therefore addresses one bounded contract: a Linux application wants to read an existing regular file whose symlink-resolved absolute location is beneath a trusted, already-resolved directory. Relative inputs and absolute paths already inside that root are supported. An internal symlink is permitted when it resolves inside. Missing targets, dangling symlinks, directories and outside targets are refused.

The controlled preparation fixture supplied for this article recorded twelve static cases plus one deliberate namespace-change case. Its preparation environment was CPython 3.12.14 on Linux 6.18.55 x86_64 with glibc 2.39, overlayfs and effective UID 0. The crucial result is not merely “replace commonprefix.” It is: resolve and check can support this declared static-tree decision; when the namespace cannot be held static through the read, withhold approval and change the design.

Define the file-location contract before choosing a helper

Start with the application rule, not with the name of a Python function.

The trusted root in this playbook is an existing, absolute directory whose own pathname has already been resolved. The fixture creates that directory itself, resolves it with strict=True, and then treats that resulting Path as the comparison root. The helper is not intended to receive an attacker-selected root, discover which directory should be trusted, decode arbitrary external representations or turn an untrusted configuration value into a trust anchor.

For a candidate to reach ACCEPT_STATIC_FILE, all of the following must be true:

  • The candidate resolves successfully as an existing pathname.

  • Its resolved absolute path is the root itself or a descendant by path components.

  • The resolved object is a regular file.

  • The filesystem namespace relevant to that pathname remains static between the check and the read.

The policy intentionally accepts both reports/good.txt and an absolute pathname already naming uploads/reports/good.txt. It intentionally accepts a symlink such as inside-link.txt when that symlink resolves to the approved file inside the root. It rejects a leaf symlink or directory symlink whose target escapes.

That is a location rule. It is not an operating-system permission decision, content-authorization decision, provenance rule or assertion that every object reachable beneath the directory is trustworthy. Linux pathname lookup separately requires directory search permission, and file access can separately fail according to process credentials and the requested operation. The kernel's pathname-resolution documentation treats those permissions as part of lookup semantics rather than as an application-level containment predicate.

Likewise, an in-root pathname does not establish who authored the bytes, whether a hard link has acceptable provenance, whether a mounted subtree should be trusted or whether the application is authorized to disclose that content. Those require additional policy that this fixture does not test.

This separation is an example of the broader security-by-design responsibilities for system engineers: identify the trust boundary and the property being enforced before selecting a mechanism. That related article discusses broader system design; here the property is narrowly the resolved location of one existing local file.

Treat namespace ownership as a precondition

A pathname is interpreted through a namespace made of directory entries. Before relying on a check-then-read sequence, identify who can rename or replace the trusted root, any ancestor needed to reach it, intermediate candidate components, symlinks and the final entry.

The laboratory's useful property is controlled ownership of the fixtures plus a no-concurrent-writer assumption for the static cases. Do not silently import that assumption into a shared upload tree, plugin directory or job workspace merely because the application created the top-level directory once.

Ownership is not a magic anti-race property. Another process running with equivalent authority may be able to rename entries. The application itself may have another worker that mutates the same tree. A platform administrator may change mounts. Even a deterministic maintenance operation can invalidate a pathname between a check and a later open.

When reviewers cannot defend the static-namespace assumption through the actual read, ACCEPT_STATIC_FILE is not available under this helper's contract. The operational result is HOLD_MUTABLE_NAMESPACE until a design that constrains lookup while opening is qualified.

Capture the runtime and construct an owned test tree

Reproducibility starts by separating documentation versions from executed software.

The supplied preparation manifest records CPython 3.12.14, built with Clang 22.1.3, on Linux-6.18.55-x86_64-with-glibc2.39, overlayfs and effective UID 0. The fixture revision is refonte-python-path-boundary-2026-10-26-r1. Those are preparation observations supplied with this playbook.

By contrast, the version-selected online Python 3.12 documentation reviewed on October 26, 2026 identifies itself as Python 3.12.15 documentation and reports a last update of October 25, 2026 at 15:50 UTC. That documentation is evidence about the documented API contract; it is not evidence that the preparation interpreter was 3.12.15.

The fixture uses TemporaryDirectory to create one locally owned working tree. Python documents TemporaryDirectory as context-manageable and removing its contents when the context finishes. The same current documentation also warns that cleanup is not robust against the tree being modified during removal, reinforcing why an owned static laboratory must not be generalized into a claim about arbitrary shared trees.

That laboratory objective is different from staged archive extraction and payload approval, which asks whether extraction left a complete approved payload. Here there is no archive, extraction or publication step: the question is which existing file one pathname reaches.

Save the following source exactly as path_boundary_lab.py. The supplied SHA-256 is:

2680f6413d1972f5410d7348909aa702773ac375d89967380ee0dad94ed80241

Run it with assertions enabled:

python3 --version
python3 path_boundary_lab.py > path-boundary-evidence.json

The source rejects optimized Python, requires Linux, takes no external pathname arguments and invokes a GNU-compatible stat -f -c %T. Shell redirection can create path-boundary-evidence.json before Python starts successfully, so the presence of that file proves nothing by itself. Require a successful process exit and complete, valid JSON.

"""Owned, inert pathname-boundary fixture; no production path arguments."""
import hashlib
import json
import os
import platform
import stat
import subprocess
import sys
import tempfile
from pathlib import Path


GOOD = b"approved local fixture\n"
OUTSIDE = b"owned outside fixture\n"


def check_existing_file(root, candidate):
    """Only for a trusted, static namespace; this returns a path, not a handle."""
    try:
        resolved = candidate.resolve(strict=True)
    except (OSError, RuntimeError) as exc:
        return {"decision": "REJECT_RESOLUTION", "exception": type(exc).__name__}, None
    details = {"resolved": str(resolved), "resolved_inside": resolved.is_relative_to(root)}
    if not details["resolved_inside"]:
        return {**details, "decision": "REJECT_OUTSIDE"}, None
    try:
        regular = stat.S_ISREG(resolved.stat().st_mode)
    except OSError as exc:
        return {**details, "decision": "REJECT_STAT", "exception": type(exc).__name__}, None
    if not regular:
        return {**details, "decision": "REJECT_NOT_REGULAR"}, None
    return {**details, "decision": "ACCEPT_STATIC_FILE"}, resolved


def representations(root, candidate):
    root_text, candidate_text = str(root), str(candidate)
    return {
        "candidate": candidate_text,
        "commonprefix_value": os.path.commonprefix([root_text, candidate_text]),
        "commonprefix_inside": os.path.commonprefix([root_text, candidate_text]) == root_text,
        "commonpath_value": os.path.commonpath([root_text, candidate_text]),
        "commonpath_inside": os.path.commonpath([root_text, candidate_text]) == root_text,
        "is_relative_to_inside": candidate.is_relative_to(root),
        "abspath_inside": Path(os.path.abspath(candidate)).is_relative_to(root),
    }


def main():
    if not debug:
        raise SystemExit("Assertions must remain enabled for this qualification run.")
    if os.name != "posix" or platform.system() != "Linux":
        raise SystemExit("This qualification fixture is scoped to Linux local paths.")
    if len(sys.argv) != 1:
        raise SystemExit("No external file or directory arguments are accepted.")
    report = {
        "fixture": "refonte-python-path-boundary-2026-10-26-r1",
        "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        "python": sys.version,
        "platform": platform.platform(),
        "effective_uid": os.geteuid(),
        "claim_scope": "existing regular-file pathname in a trusted static Linux tree",
        "static_cases": [],
    }
    with tempfile.TemporaryDirectory(prefix="refonte-path-boundary-") as name:
        workspace = Path(name).resolve(strict=True)
        root = workspace / "uploads"
        sibling = workspace / "uploads-archive"
        (root / "reports").mkdir(parents=True)
        (sibling / "nested").mkdir(parents=True)
        (root / "reports" / "good.txt").write_bytes(GOOD)
        (sibling / "outside.txt").write_bytes(OUTSIDE)
        (root / "escape.txt").symlink_to(sibling / "outside.txt")
        (root / "escape-dir").symlink_to(sibling, target_is_directory=True)
        (root / "inside-link.txt").symlink_to("reports/good.txt")
        (root / "dangling.txt").symlink_to("missing.txt")
        (root / "link-to-nested").symlink_to(sibling / "nested", target_is_directory=True)
        root = root.resolve(strict=True)
        report["workspace"] = str(workspace)
        report["root"] = str(root)
        report["filesystem"] = subprocess.run(
            ["stat", "-f", "-c", "%T", str(workspace)],
            check=True, text=True, capture_output=True,
        ).stdout.strip()

        # The expected decision and raw comparator values are authored, not
        # derived from the implementation being evaluated.
        cases = [
            ("S01", "ordinary nested file", "reports/good.txt", "ACCEPT_STATIC_FILE", (True, True, True)),
            ("S02", "sibling absolute prefix", str(sibling / "outside.txt"), "REJECT_OUTSIDE", (True, False, False)),
            ("S03", "parent traversal", "../uploads-archive/outside.txt", "REJECT_OUTSIDE", (True, True, True)),
            ("S04", "external leaf symlink", "escape.txt", "REJECT_OUTSIDE", (True, True, True)),
            ("S05", "external directory symlink", "escape-dir/outside.txt", "REJECT_OUTSIDE", (True, True, True)),
            ("S06", "internal symlink", "inside-link.txt", "ACCEPT_STATIC_FILE", (True, True, True)),
            ("S07", "parent step stays inside", "reports/../reports/good.txt", "ACCEPT_STATIC_FILE", (True, True, True)),
            ("S08", "missing leaf", "missing.txt", "REJECT_RESOLUTION", (True, True, True)),
            ("S09", "dangling symlink", "dangling.txt", "REJECT_RESOLUTION", (True, True, True)),
            ("S10", "root is a directory", ".", "REJECT_NOT_REGULAR", (True, True, True)),
            ("S11", "absolute file inside root", str(root / "reports" / "good.txt"), "ACCEPT_STATIC_FILE", (True, True, True)),
            ("S12", "symlink followed by parent step", "link-to-nested/../outside.txt", "REJECT_OUTSIDE", (True, True, True)),
        ]
        for case_id, label, supplied, expected, raw_expected in cases:
            candidate = root / supplied
            row = {"case": case_id, "label": label, "supplied": supplied}
            row.update(representations(root, candidate))
            actual_raw = tuple(row[key] for key in (
                "commonprefix_inside", "commonpath_inside", "is_relative_to_inside"
            ))
            assert actual_raw == raw_expected, (case_id, actual_raw, raw_expected)
            result, approved_path = check_existing_file(root, candidate)
            row.update(result)
            assert row["decision"] == expected, (case_id, row, expected)
            if approved_path is not None:
                data = approved_path.read_bytes()
                assert data == GOOD, (case_id, data)
                row["approved_read_hex"] = data.hex()
            # Deliberately inadequate gate, confined to known fixture inputs.
            # This is evidence of a bad decision, never a recommended reader.
            if row["commonprefix_inside"] and candidate.is_file():
                unsafe_data = candidate.read_bytes()
                assert unsafe_data in (GOOD, OUTSIDE)
                row["bad_prefix_gate_read_hex"] = unsafe_data.hex()
            if case_id == "S12":
                assert row["abspath_inside"] is True
                assert row["resolved_inside"] is False
            row["oracle_passed"] = True
            report["static_cases"].append(row)

        # Deterministic namespace change between checking and opening. There
        # is no concurrent attacker and no timing-dependent race experiment.
        changed_root = workspace / "change-case"
        (changed_root / "reports").mkdir(parents=True)
        (changed_root / "reports" / "good.txt").write_bytes(GOOD)
        changed_outside = workspace / "change-outside"
        changed_outside.mkdir()
        (changed_outside / "good.txt").write_bytes(OUTSIDE)
        before, approved_path = check_existing_file(
            changed_root, changed_root / "reports" / "good.txt"
        )
        assert before["decision"] == "ACCEPT_STATIC_FILE"
        original_dir = changed_root / "reports-original"
        (changed_root / "reports").rename(original_dir)
        try:
            (changed_root / "reports").symlink_to(changed_outside, target_is_directory=True)
            after_change = approved_path.read_bytes()
            assert after_change == OUTSIDE
            after, = checkexisting_file(changed_root, changed_root / "reports" / "good.txt")
            assert after["decision"] == "REJECT_OUTSIDE"
            report["namespace_change_case"] = {
                "case": "S13",
                "checked_path": str(approved_path),
                "before": before,
                "static_namespace_precondition_met": False,
                "cached_path_read_hex_after_change": after_change.hex(),
                "fresh_check_after_change": after,
                "operational_decision": "HOLD_MUTABLE_NAMESPACE",
            }
        finally:
            link = changed_root / "reports"
            if link.is_symlink():
                link.unlink()
            original_dir.rename(link)
        restored, restored_path = check_existing_file(changed_root, changed_root / "reports" / "good.txt")
        assert restored["decision"] == "ACCEPT_STATIC_FILE"
        assert restored_path.read_bytes() == GOOD
        report["namespace_change_case"]["restored_read_hex"] = GOOD.hex()
        report["namespace_change_case"]["oracle_passed"] = True
    report["owned_workspace_removed"] = not workspace.exists()
    assert report["owned_workspace_removed"]
    report["case_count"] = len(report["static_cases"]) + 1
    report["all_oracles_passed"] = True
    print(json.dumps(report, indent=2, sort_keys=True))


if name == "__main__":
    main()

Author the expected bytes before running the checks

The fixture defines two independent byte strings before testing any predicate:

GOOD = b"approved local fixture\n"
OUTSIDE = b"owned outside fixture\n"

Their preparation hexadecimal representations are respectively:

617070726f766564206c6f63616c20666978747572650a
6f776e6564206f75747369646520666978747572650a

The expected decisions and the raw commonprefix/commonpath/is_relative_to boolean tuples are also authored as test inputs. That matters because a security qualification must compare an implementation against an independent oracle. Computing “expected containment” by calling the same containment predicate being reviewed would only demonstrate internal consistency.

Effective UID 0 is recorded because execution identity is relevant environmental evidence. It must not be rewritten as an unprivileged test. More importantly, this fixture does not attempt to demonstrate discretionary-access-control isolation: its objects are created for the running process and the test asks where pathnames resolve. Linux documents separate permission effects during pathname traversal, including directory search checks and capabilities associated with an effective filesystem UID of zero.

Reproduce the similarly named sibling passing commonprefix

S02 isolates the first failure mode without symlinks or missing objects.

Suppose the trusted root is conceptually:

/tmp/.../uploads

and the candidate is the owned sibling file:

/tmp/.../uploads-archive/outside.txt

os.path.commonprefix() does exactly what its name says at the string level: it returns the longest character prefix shared by its inputs. Python's documentation explicitly distinguishes that from commonpath(), which finds a common sub-path, and warns that the commonprefix() algorithm is not secure for finding a pathname prefix.

For S02 the preparation matrix is:

Comparator

Observed raw result

What that result establishes

commonprefix == root

true

The candidate string begins with the complete root string

commonpath == root

false

The candidate is not lexically under that root by path components

candidate.is_relative_to(root)

false

The candidate is not lexically relative to the root

Resolved location inside root

false

The existing target resolves outside

The returned character prefix is the entire uploads root spelling because uploads-archive begins with those same characters. The hyphen belongs to the next character, not to a pathname-boundary check performed by commonprefix().

The fixture then deliberately demonstrates why this matters. Its intentionally inadequate branch asks only whether the character-prefix test passed and whether candidate.is_file() is true. S02 satisfies both, so that branch reads the independently authored OUTSIDE bytes.

That branch exists only as controlled evidence using fixture-owned inert content. It is not a fallback reader for rejected application input.

The lesson is precise rather than universal: commonprefix() is useful when the problem really is a common sequence of characters. It has not established the component-ancestry requirement in this file-location contract. Renaming the sibling would merely hide this test case; it would not repair the decision rule.

Separate component ancestry from resolved location

Moving from characters to path components fixes S02's specific ambiguity, but component-aware does not mean filesystem-resolved.

Python documents os.path.commonpath() as returning the longest common sub-path and raising ValueError for incompatible forms such as mixed absolute and relative pathnames or, on relevant platforms, different drives. It does not claim that commonpath() follows symlinks or performs live filesystem lookup.

That distinction becomes visible by comparing S02 with S03.

Case

Raw spelling

commonprefix

commonpath

is_relative_to

Resolved result

S02

absolute sibling uploads-archive/outside.txt

true

false

false

outside

S03

../uploads-archive/outside.txt joined to root

true

true

true

outside

S02 demonstrates why component ancestry is better than character-prefix equivalence for a similarly named sibling. S03 demonstrates why lexical component ancestry is still not the final admission predicate.

For S03, the constructed candidate contains the trusted root components followed by a .. component. Both the observed commonpath comparison and Path.is_relative_to() report the raw candidate as inside. Yet resolving the path applies the parent step and reaches the sibling directory.

Python's Path.is_relative_to() documentation is especially explicit: the method is string-based, does not access the filesystem and does not treat .. specially. That is exactly the property the S03 observation exposes.

This is not a criticism of is_relative_to(). After resolve(strict=True), the helper uses resolved.is_relative_to(root), which is an appropriate component relationship check because the path being compared has already been resolved for this contract.

The distinction resembles, but must not be conflated with, URL-origin admission before the first request. That workflow decides whether a resolved network destination has an approved origin. This one decides whether a resolved local filesystem pathname is beneath a directory. Different representations require different admission predicates and evidence.

Declare what absolute input means in this interface

An interface must decide what an absolute supplied path means rather than relying on intuition about “joining it to the root.”

Python's path model specifies that when path construction encounters an absolute segment, the preceding path segments are ignored. In other words, root / absolute_candidate does not force that absolute path underneath root; the absolute segment replaces the prior base.

That behavior is intentional in this fixture because the policy intentionally supports absolute inputs.

S11 supplies the absolute pathname of root/reports/good.txt. It resolves inside and is accepted.

S02 supplies the absolute pathname of uploads-archive/outside.txt. It remains that outside pathname and is rejected.

A different application could declare “relative inputs only.” That would be a legitimate interface restriction, but it would not solve the containment problem demonstrated here. S03, S04, S05 and S12 begin with relative spellings yet escape through .. or symlink semantics.

Absolute-versus-relative syntax is therefore one input-policy dimension; resolved location is the authorization predicate required by this particular contract.

Check where leaf and directory symlinks actually resolve

A pathname that lexically starts beneath uploads can leave that tree through a symbolic link.

S04 creates:

uploads/escape.txt -> uploads-archive/outside.txt

The raw candidate uploads/escape.txt satisfies all three lexical checks in the preparation matrix: commonprefix, commonpath and is_relative_to are true. Path.resolve(strict=True), however, follows the symlink and produces the outside target. The helper then returns REJECT_OUTSIDE.

S05 moves the same idea into a nonfinal component:

uploads/escape-dir -> uploads-archive
uploads/escape-dir/outside.txt

Again the raw representation looks contained. Linux pathname resolution explains why that does not control the result: nonfinal components are looked up one by one, and when such a component is a symbolic link resolving to a directory, lookup continues from that resolved directory.

Python's Path.resolve() documentation describes the corresponding high-level behavior: resolve() makes a path absolute while resolving symbolic links, and it eliminates .. components during that resolution. With strict=True, a nonexistent target raises rather than being silently left as an unresolved suffix.

For an existing-file admission boundary, this is why a pathlib resolve symlink validation step belongs before the ancestry check. The application needs to compare the location that the symlink chain denotes, not only the spelling used to reach it.

The policy still does not become “symlinks are dangerous, therefore reject all symlinks.” Its rule is location after resolution.

Preserve the positive internal-symlink control

S06 exists to prevent an over-repair.

The fixture creates:

uploads/inside-link.txt -> reports/good.txt

Its resolved location is the same in-root file used by S01:

.../uploads/reports/good.txt

The result is ACCEPT_STATIC_FILE, and reading the returned resolved path produces the exact authored GOOD bytes:

617070726f766564206c6f63616c20666978747572650a

An implementation that reacts to S04 and S05 by banning every symlink would reject S06 and therefore fail this declared contract. A production service may certainly choose a stricter “no symlinks” policy, but that would be a different rule requiring its own specification and tests.

Positive controls matter in security reviews because indiscriminate rejection can appear secure while silently breaking supported behavior. Here the required distinction is not symlink versus non-symlink. It is resolved target inside versus resolved target outside.

Resolve symlinks and parent components in the right order

Ordinary parent components are not automatically escapes. Their meaning depends on where lookup is when .. is encountered.

S07 supplies:

reports/../reports/good.txt

There is no redirecting symlink in that prefix. Resolution steps into reports, applies .. back to uploads, then re-enters reports. The final location remains uploads/reports/good.txt, so S07 is accepted and returns GOOD.

S12 is the counterexample that catches an attractive but incorrect normalization strategy. The fixture creates:

uploads/link-to-nested -> uploads-archive/nested

and supplies:

link-to-nested/../outside.txt

os.path.abspath() does not perform symlink resolution. Python describes abspath() as producing a normalized absolute version, generally through normpath(join(...)); the pathlib documentation additionally cautions that removing .. without resolving symlinks can change a path's meaning.

A lexical collapse therefore makes S12 look like:

.../uploads/outside.txt

which appears contained. The preparation fixture records abspath_inside == True.

Actual pathname resolution has different ordering. Linux first resolves link-to-nested to the sibling's nested directory; only then does the following .. refer to that directory's parent. The effective walk is therefore:

uploads/link-to-nested
    -> uploads-archive/nested
    -> ..
    -> uploads-archive
    -> outside.txt

The final target is outside, so resolve(strict=True) records resolved_inside == False and the helper returns REJECT_OUTSIDE. Linux's component-by-component algorithm and Python's preservation of .. in pure path manipulation explain why this order matters.

This is the important os.path.abspath symlink parent path caveat: lexical normalization and filesystem resolution are not interchangeable repairs. Normalizing away .. before accounting for a preceding symlink can describe a different location from the one the operating system would reach.

Reject missing targets and contained directories explicitly

Containment is necessary here, but it is not sufficient to accept an existing regular file.

S08 supplies missing.txt. Path.resolve(strict=True) cannot resolve the required existing leaf and the preparation records FileNotFoundError, classified as REJECT_RESOLUTION.

S09 supplies a dangling symlink whose target is missing.txt. The link object itself exists, but the contract requires the target to be an existing file. Strict resolution again records FileNotFoundError and REJECT_RESOLUTION. Python documents that strict=True raises when the path does not exist; non-strict resolution instead resolves as far as possible and appends the unresolved remainder. That non-strict behavior is not sufficient for this existing-file policy.

The correct response to either case is not to retry with strict=False, create the missing leaf or fall through to reading the original candidate. Those actions would change the contract from “read an existing approved file” to something else.

S10 demonstrates the next distinction. The candidate . resolves to the trusted root itself. It is therefore spatially contained. But its stat() mode describes a directory rather than a regular file, so the helper returns REJECT_NOT_REGULAR.

Python documents Path.stat() as performing a status lookup on each call; the helper applies stat.S_ISREG() to that mode so the type requirement is explicit rather than inferred from location.

REJECT_STAT is also present for an OSError raised by the status lookup after resolution. No supplied case induces that result. It is therefore a defined helper outcome, not a thirteenth static negative control and not grounds for inflating the matrix.

Other pathname representations, exception combinations and filesystem failures remain unqualified until separately exercised.

Implement the bounded static-tree admission decision

The helper is short because its contract is narrow:

resolve existing target
        |
        v
is resolved location beneath trusted resolved root?
        |
        v
is resolved object a regular file?
        |
        v
return resolved pathname

First, candidate.resolve(strict=True) asks the filesystem what existing location the pathname denotes after symlinks and parent components are interpreted. FileNotFoundError and other handled OSError conditions, plus RuntimeError such as a resolution loop, produce REJECT_RESOLUTION.

Second, the helper asks:

resolved.is_relative_to(root)

This is intentionally different from applying is_relative_to() to the unprocessed candidate. The method itself remains lexical; what has changed is its input. At this point both root and resolved are absolute, symlink-resolved pathnames under the helper's preconditions. The component relation now answers the required “beneath root” question for the resolution just performed. Python documents is_relative_to() as string-based and filesystem-independent, which is why the ordering is central.

Third, resolved.stat() supplies current file metadata and stat.S_ISREG() enforces the required object type. A directory cannot become accepted merely because it is in the right location.

Finally, the success branch returns the resolved Path, not the raw input:

return {**details, "decision": "ACCEPT_STATIC_FILE"}, resolved

That return value lets the caller avoid validating one representation and casually rebuilding another before the read.

The preconditions still matter more than the helper's compactness. root must already be the trusted, existing, absolute, resolved directory chosen by the application owner. The routine does not determine whether the root itself is authorized. It does not decode URLs, archive names or application-specific aliases. It does not approve contents. It does not prove hard-link provenance. It does not defend mount replacement.

Most importantly, it returns a pathname, not an already-open file handle. Its ACCEPT_STATIC_FILE decision is valid only for the declared static namespace through the subsequent read.

That is the bounded answer to resolved path file authorization in this article: authorization concerns the resolved location and required type under a stable namespace, not a generic transformation called “sanitize path.”

Preserve legitimate reads while rejecting outside targets

The static matrix contains four positive cases, and each protects a useful part of the interface.

S01 proves that the ordinary nested relative file works:

reports/good.txt

It resolves inside, is regular and reads GOOD.

S06 proves that an internal symbolic link remains usable. Its original representation is different from S01, but both resolve to the approved in-root file and both return identical expected bytes.

S07 proves that an ordinary .. component is not automatically disqualifying. The parent step is resolved according to filesystem semantics and ultimately remains inside.

S11 proves the declared absolute-input policy. An absolute path already naming root/reports/good.txt is accepted rather than rejected solely for beginning with /.

Together those controls prevent several common overcorrections: “reject every absolute path,” “reject every .. string” and “reject every symlink.” None matches the stated contract.

The eight static rejections establish complementary boundaries. S02 catches the sibling string-prefix false positive. S03 catches lexical parent traversal. S04 and S05 catch leaf and directory symlink escapes. S08 and S09 enforce existence. S10 enforces regular-file type. S12 catches normalization of a symlink-plus-parent sequence.

The exact byte oracle is important because a successful read_bytes() by itself would be ambiguous. Every file in this fixture is valid, locally authored and readable. The question is whether the program reached the authorized fixture. Comparing accepted reads to GOOD answers that independently of the path predicate.

No filename-encoding, alternate-platform or remote-filesystem claim follows from these cases. They qualify the finite Linux-local inputs actually specified.

Read the returned candidate without rebuilding the input

Under the static-tree contract, the normal success path should consume approved_path, the resolved Path returned by the helper.

Do not approve:

root / supplied

and then later pass supplied through a second decoder, substitute a different base, reinterpret it as another pathname type or reconstruct the original lexical candidate. A boundary review should be able to trace which exact representation was checked and which representation was read.

That discipline removes one class of representation mismatch: the caller does not validate the resolved target and then accidentally reopen an independently reconstructed request.

It does not make the pathname immutable. The returned Path still contains a pathname string whose components will be looked up again when read_bytes() opens it. S13 deliberately demonstrates that remaining limit.

Change one directory after the check and observe the limit

S13 is the point where this playbook stops granting static-path approval.

The fixture creates a separate owned tree:

change-case/reports/good.txt

containing GOOD, plus:

change-outside/good.txt

containing OUTSIDE.

It first calls check_existing_file() on change-case/reports/good.txt. At that moment the pathname resolves in the root, is regular and returns ACCEPT_STATIC_FILE. The returned approved_path is cached.

Then the fixture deliberately changes the namespace in a deterministic sequence. It renames the original reports directory to reports-original and places a symlink at the old reports name pointing to change-outside.

There is no external attacker, scheduler-dependent timing or concurrency benchmark. The fixture itself performs both operations in a fixed order solely to invalidate its own static-namespace precondition.

Next comes the decisive read:

after_change = approved_path.read_bytes()

Although approved_path was produced by successful resolution earlier, its spelling is still conceptually:

.../change-case/reports/good.txt

When opened now, reports is looked up again and follows the newly installed symlink. The preparation records the OUTSIDE bytes:

6f776e6564206f75747369646520666978747572650a

A fresh call to check_existing_file() after the replacement resolves through that symlink and correctly returns REJECT_OUTSIDE.

The operational decision is nevertheless not “run the check twice.” It is HOLD_MUTABLE_NAMESPACE.

Why? Because the first read already proved that checking a pathname at time A does not freeze what that pathname will resolve to at time B. A second precheck merely introduces another interval before another open. It can detect the deterministic state shown here after mutation, but it does not atomically bind validation to later use.

S13 is therefore evidence for the helper's boundary: static-tree admission is supported; mutable-namespace confinement is not.

A resolved pathname is not a retained open handle

A Python Path object is an immutable Python value representing a pathname; it is not a retained kernel reference to the file that happened to occupy that pathname when resolve() ran.

When read_bytes() later opens the pathname, the operating system performs pathname lookup again. Linux documents lookup as a component-by-component process, including symlink handling.

When that repeated lookup cannot be assumed stable, enforcement needs to move into a different design layer.

Linux documents openat2() resolution controls intended to restrict how paths are resolved relative to a directory file descriptor. In particular, RESOLVE_BENEATH in openat2(2) prevents path resolution from succeeding when a component escapes the directory represented by dirfd. That is conceptually different from resolving a pathname in user space, comparing it and then opening it later: the restriction participates in the kernel's opening operation itself.

This article does not implement openat2(), qualify Python bindings, prescribe a flag combination or claim a general sandbox. The documentation is escalation context for the platform owner: if the namespace is mutable, the current helper's approval model is insufficient and the opening mechanism needs a separate design and review.

Reconcile the thirteen cases and preserve the evidence

The case ledger is the evidence map, not merely a list of interesting path strings. P/C/R below means the raw preparation booleans for commonprefix, commonpath and is_relative_to; they do not mean authorization.

Case

Owned input or control

P/C/R

Preparation observation

Decision meaning

S01

reports/good.txt

true/true/true

Resolved in root; exact GOOD bytes

ACCEPT_STATIC_FILE

S02

Absolute uploads-archive/outside.txt

true/false/false

Bad prefix branch reads OUTSIDE; resolved target outside

REJECT_OUTSIDE

S03

../uploads-archive/outside.txt

true/true/true

Resolved target outside

REJECT_OUTSIDE

S04

escape.txt, external leaf symlink

true/true/true

Resolved target outside

REJECT_OUTSIDE

S05

escape-dir/outside.txt, directory symlink

true/true/true

Resolved target outside

REJECT_OUTSIDE

S06

inside-link.txt

true/true/true

Internal symlink resolves in root; GOOD bytes

ACCEPT_STATIC_FILE

S07

reports/../reports/good.txt

true/true/true

Parent step resolves in root; GOOD bytes

ACCEPT_STATIC_FILE

S08

missing.txt

true/true/true

FileNotFoundError during strict resolution

REJECT_RESOLUTION

S09

dangling.txt

true/true/true

FileNotFoundError during strict resolution

REJECT_RESOLUTION

S10

Root directory itself

true/true/true

Resolved inside but not a regular file

REJECT_NOT_REGULAR

S11

Absolute reports/good.txt inside root

true/true/true

Resolved inside; GOOD bytes

ACCEPT_STATIC_FILE

S12

link-to-nested/../outside.txt

true/true/true

abspath appears inside; resolved target outside

REJECT_OUTSIDE

S13

Owned directory replacement after check

not a raw-comparator case

Cached path reads OUTSIDE; fresh check rejects; restoration rereads GOOD

HOLD_MUTABLE_NAMESPACE

S01 through S12 therefore contain four accepted files and eight rejected candidates. S13 is not a ninth static rejection; it is a separate demonstration that the static-namespace precondition was invalidated.

REJECT_STAT exists in the helper but has no induced case in this suite. It should remain visible in application handling without being mislabeled as measured evidence.

A qualifying reproduction should preserve enough state to diagnose disagreements: source hash, interpreter version, platform, effective UID, filesystem type, workspace/root strings, raw candidate, raw comparator results, resolved location where available, decision, exception category, approved or deliberately bad-prefix read bytes, oracle result and cleanup result.

The temporary pathname itself is only a run-specific identifier. Once TemporaryDirectory cleanup has completed, do not copy a printed /tmp/refonte-path-boundary-... path into another procedure and treat it as evidence.

The acceptance checklist is:

  1. The source SHA-256 matches the published source.

  2. The actual environment is recorded rather than inferred from documentation.

  3. The process exits successfully.

  4. Output is complete, valid JSON rather than an empty or partial redirected file.

  5. There are twelve static records plus S13.

  6. Every oracle passes.

  7. Accepted and rejected cases retain the declared distinctions.

  8. owned_workspace_removed is true.

A failed assertion, failed setup command, missing JSON, unsupported platform or cleanup failure leaves the run unqualified. Do not remove the disagreeing case to obtain a green result.

That evidence-reconciliation discipline resembles reconciling a protected-subtree cleanup, but the operations are deliberately different. That playbook reconciles removal effects; this one reconciles pathname representations and read targets.

Restore the controlled namespace and reopen the decision

S13 includes restoration because a deterministic destructive control should leave its owned fixture in a known state before the context manager removes the workspace.

The finally block first checks whether the replacement reports entry is the symlink installed by the fixture. If so, it unlinks that owned symlink. It then renames the original owned reports-original directory back to reports.

After restoration, the fixture does not reuse the old decision. It calls the admission helper again:

restored, restored_path = check_existing_file(
    changed_root,
    changed_root / "reports" / "good.txt"
)

The required result is a fresh ACCEPT_STATIC_FILE, followed by a fresh read of the exact GOOD bytes. That demonstrates that the controlled laboratory layout was restored and that the static contract again holds for that fresh operation.

It does not erase what happened before restoration. The cached pathname already returned OUTSIDE during the invalidated state. Repairing a namespace cannot retract bytes that an application has previously read or disclosed.

That distinction should shape real operational response. When observed evidence contradicts the application's namespace assumptions, stop issuing further admissions that rely on those assumptions. Preserve the relevant decision records, pathname representations and environmental evidence. Assign the owner responsible for the mutable namespace or check/read design. Repair the underlying cause, and then rerun the controls applicable to the new design.

REPAIR_AND_RERUN is an owner action, not another harness outcome. No row in the thirteen-case matrix reports it.

Likewise, do not turn this restoration example into generic filesystem cleanup guidance. The fixture knows exactly which objects it created and modifies only those objects. It supplies no justification for recursively deleting unknown trees, changing ACLs, rewriting permissions or overwriting application data.

A clean rerun after restoration qualifies the new read under the declared conditions. It does not prove that a shared production tree is immutable, that earlier disclosure had no consequence or that a platform-level opening design has been qualified.

Choose acceptance, rejection, repair or hold with an owner

The operational value of the matrix is that each result has a specific meaning and an owner. Avoid compressing every non-success into “invalid path,” because the remediation differs substantially between an outside target, an absent file, a type mismatch and an invalid namespace assumption.

Decision

Evidence condition

Next action

Primary owner

ACCEPT_STATIC_FILE

Existing regular file resolves beneath trusted resolved root; static namespace precondition remains valid

Read the returned resolved path under the bounded contract

Application owner

REJECT_OUTSIDE

Strict resolution succeeds but resolved location is not beneath root

Do not read; review caller or expected-path policy if surprising

Application security / application owner

REJECT_RESOLUTION

Resolution raises handled OSError or RuntimeError

Do not fall back; investigate missing, dangling or otherwise unresolved input

Application owner

REJECT_STAT

A status lookup on the resolved in-root target fails

Withhold read; retain exception evidence and investigate environmental cause

Application owner / platform owner

REJECT_NOT_REGULAR

Target resolves inside but is not a regular file

Reject under this file-read contract

Application owner

REPAIR_AND_RERUN

Root policy, fixture, namespace ownership or implementation is found incorrect but repairable

Repair the cause, preserve evidence and rerun applicable controls

Owner of the failed assumption

HOLD_MUTABLE_NAMESPACE

Namespace can change between validation and open, or stability cannot be defended

Do not approve this helper as confinement; design and qualify stronger opening semantics

Platform owner with application security

Application security owns the location and supported-input contract: what counts as “inside,” whether absolute inputs are supported, whether internal symlinks are permitted and which unsupported representations must be rejected.

The application owner owns transformations between input, check and read. That includes making sure an accepted resolved path is the object passed to the static-tree read branch instead of reconstructing the original request.

The platform owner owns the trust conditions that make the namespace static enough for that design and, where those conditions cannot be maintained, the stronger opening mechanism.

The data owner owns consequence review if a control failure caused unexpected bytes to be returned. A filesystem repair is not automatically a data-impact disposition.

Regression should be repeated whenever a change affects the assumptions under test: root policy, interpreter behavior, filesystem arrangement, input decoding, path construction or concurrency model. Broader automated security-testing foundations can place such controls into a repeatable engineering workflow, but automation does not make this finite matrix universal coverage.

The strongest review question remains simple: what exact property did the successful predicate establish? commonprefix establishes shared characters. commonpath establishes a common component path for its pathname representations. is_relative_to establishes lexical ancestry. resolve(strict=True) establishes the absolute, symlink-resolved interpretation of an existing path at the time resolution occurs. Only after those distinctions are correctly ordered can the application make the bounded static-file decision.

Build stronger application-security verification habits

A good Python directory traversal validation review is less about memorizing one “safe path” snippet than about separating representation, resolution, admission and use.

For this contract, commonprefix() is insufficient because characters are not directory boundaries. Raw commonpath() or raw Path.is_relative_to() is insufficient because lexical ancestry does not account for filesystem symlinks and, in the demonstrated cases, can retain misleading parent-component relationships. resolve(strict=True) supplies the existing resolved location needed by the policy, after which component ancestry and regular-file status can support ACCEPT_STATIC_FILE, but only while the namespace remains static through the read.

Engineers who want to strengthen the surrounding review discipline can explore Refonte Learning's Cybersecurity & DevSecOps programme. Its published material lists secure-coding practices, OWASP and threat-modeling foundations, DAST/SAST/IAST, secure CI/CD, incident response and security-workflow automation, together with practical project and virtual-internship elements. Those are broader security foundations; the programme page does not establish that this particular pathlib laboratory, its TOCTOU boundary or openat2() design is taught.

The acceptance boundary remains narrower and operational: approve only an existing regular file whose resolved location is beneath the trusted resolved root when the namespace can be kept static through use; reject unsuitable targets, and hold rather than overclaim when that namespace guarantee is unavailable.