Skip to content

paths

napt.paths

Safe handling of externally supplied names that become filesystem paths.

Three kinds of text reach the filesystem from outside NAPT: the filename a download server announces, the recipe id, and the version reported by an installer or scraped from a page. Joined into a path unchecked, a value containing .. leaves the folder NAPT meant to use, and a filename can carry characters that run as code once it lands in a PowerShell script.

safe_filename

safe_filename(raw: str) -> str | None

Reduces a server-supplied filename to one safe to save and to script.

Keeps only the final path component, so .. segments and absolute paths cannot move the file. Removes characters Windows forbids and replaces the characters that act inside a PowerShell string with an underscore, because the name is later substituted into recipe-authored PowerShell whose quoting NAPT does not control.

Parameters:

Name Type Description Default
raw str

Filename from a Content-Disposition header or a URL path, already percent-decoded.

required

Returns:

Type Description
str | None

The cleaned filename, or None when nothing usable remains (empty,

str | None

only dots, or a reserved device name).

Example

Clean a hostile filename:

safe_filename("../../setup$(calc).msi")  # Returns: "setup_(calc).msi"
safe_filename("..")                      # Returns: None

Source code in napt/paths.py
def safe_filename(raw: str) -> str | None:
    """Reduces a server-supplied filename to one safe to save and to script.

    Keeps only the final path component, so ``..`` segments and absolute
    paths cannot move the file. Removes characters Windows forbids and
    replaces the characters that act inside a PowerShell string with an
    underscore, because the name is later substituted into recipe-authored
    PowerShell whose quoting NAPT does not control.

    Args:
        raw: Filename from a Content-Disposition header or a URL path,
            already percent-decoded.

    Returns:
        The cleaned filename, or None when nothing usable remains (empty,
        only dots, or a reserved device name).

    Example:
        Clean a hostile filename:
            ```python
            safe_filename("../../setup$(calc).msi")  # Returns: "setup_(calc).msi"
            safe_filename("..")                      # Returns: None
            ```

    """
    name = raw.replace("\\", "/").rsplit("/", 1)[-1]
    name = _FORBIDDEN_RE.sub("", name)
    name = _POWERSHELL_ACTIVE_RE.sub("_", name)
    # Windows silently drops trailing dots and spaces, so "evil.exe." and
    # "evil.exe" are the same file.
    name = name.strip().rstrip(". ")
    if not name or _is_reserved(name):
        return None
    return name

is_safe_path_component

is_safe_path_component(value: str) -> bool

Reports whether a value can be used as a folder name as-is.

Accepts letters, digits, dot, hyphen, underscore, and plus, starting with a letter or digit. Rejects anything containing a path separator or .., names ending in a dot, and reserved device names.

Parameters:

Name Type Description Default
value str

Recipe id, a version string, or a release tag.

required

Returns:

Type Description
bool

True when joining the value onto a directory stays inside it.

Source code in napt/paths.py
def is_safe_path_component(value: str) -> bool:
    """Reports whether a value can be used as a folder name as-is.

    Accepts letters, digits, dot, hyphen, underscore, and plus, starting with
    a letter or digit. Rejects anything containing a path separator or ``..``,
    names ending in a dot, and reserved device names.

    Args:
        value: Recipe ``id``, a version string, or a release tag.

    Returns:
        True when joining the value onto a directory stays inside it.

    """
    return (
        _SAFE_COMPONENT_RE.fullmatch(value) is not None
        and ".." not in value
        and not value.endswith(".")
        and not _is_reserved(value)
    )