github/spec-kit · error · OSError

ENOTDIR

ENOTDIR

Error message

Download file changed between creation and open

What it means

Post-open TOCTOU defense (_verify_leaf_identity): after opening the transient download file, the fstat of the open descriptor is compared against an lstat of the pathname; if the pathname is no longer a regular file or its device/inode pair differs, the leaf was swapped (e.g. replaced by a symlink) between creation and use, and an ENOTDIR OSError aborts before any write. It mirrors the staged-file check used by the workflow installer.

Source

Thrown at src/specify_cli/extensions/_commands.py:622

    return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))


def _verify_leaf_identity(fd: int, path: Path) -> None:
    """Confirm ``fd`` still refers to the regular file at ``path``.

    Mirrors the workflow installer's staged-file check: comparing the open
    descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
    that was swapped for a symlink/reparse point between creation and use, so
    the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
    """
    path_stat = path.stat(follow_symlinks=False)
    open_stat = os.fstat(fd)
    if (
        not stat.S_ISREG(path_stat.st_mode)
        or path_stat.st_dev != open_stat.st_dev
        or path_stat.st_ino != open_stat.st_ino
    ):
        raise OSError(
            errno.ENOTDIR, "Download file changed between creation and open"
        )


def _validate_safe_cache_dir(project_root: Path) -> Path:
    """Create and validate the extension URL download cache one component at a
    time, refusing symlinked/junctioned components on every supported platform."""
    download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
    try:
        if _has_secure_dir_fd():
            _validate_cache_dir_via_dir_fd(project_root, download_dir)
        else:
            _validate_cache_dir_via_paths(project_root, download_dir)
    except typer.Exit:
        raise
    except FileExistsError:
        console.print(
            "[red]Error:[/red] Refusing to use symlinked download cache directory"

View on GitHub (pinned to bf88c9f9a8)

Solutions

  1. Treat as a security signal: stop, inspect the cache directory for unexpected symlinks (`find .specify -type l`), and recreate the download cache area fresh
  2. Remove world/group write access to the project and .specify (`chmod -R go-w .specify`) so other users cannot interfere
  3. Pause file-sync/AV tooling that touches .specify during installs, or exclude .specify from sync
  4. Retry the install after cleanup — a transient race from misbehaving tooling clears on a clean run
Defensive patterns

Strategy: try-catch

Try / catch

import errno
from specify_cli.extensions import ExtensionError

try:
    install_from_url_cmd(project_root, url, speckit_version)
except ExtensionError as e:
    cause = e.__cause__
    if isinstance(cause, OSError) and cause.errno == errno.ENOTDIR and \
            'changed between creation and open' in cause.strerror:
        # security race: audit cache dir for symlinks, tighten perms, retry once

Prevention

When it happens

Trigger: Only fires if something concurrently replaces the freshly O_EXCL-created cache file (unlink + symlink swap) in the microseconds between os.open and the identity check — i.e. an active local attacker or an aggressive antivirus/sync tool manipulating the cache directory.

Common situations: Practically never in normal use; theoretically possible on multi-user machines where .specify is shared or world-writable, or with buggy file-sync daemons rewriting files in the cache area.

Related errors


AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14). Data as JSON: /api/errors/a7c53bd247ee22b1. Report an issue: GitHub.