{"record":{"id":"a7c53bd247ee22b1","repo":"github/spec-kit","slug":"enotdir","errorCode":"ENOTDIR","errorMessage":"Download file changed between creation and open","messagePattern":"Download file changed between creation and open","errorType":"exception","errorClass":"OSError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/extensions/_commands.py","lineNumber":622,"sourceCode":"    return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, \"EMLINK\", -1))\n\n\ndef _verify_leaf_identity(fd: int, path: Path) -> None:\n    \"\"\"Confirm ``fd`` still refers to the regular file at ``path``.\n\n    Mirrors the workflow installer's staged-file check: comparing the open\n    descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf\n    that was swapped for a symlink/reparse point between creation and use, so\n    the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.\n    \"\"\"\n    path_stat = path.stat(follow_symlinks=False)\n    open_stat = os.fstat(fd)\n    if (\n        not stat.S_ISREG(path_stat.st_mode)\n        or path_stat.st_dev != open_stat.st_dev\n        or path_stat.st_ino != open_stat.st_ino\n    ):\n        raise OSError(\n            errno.ENOTDIR, \"Download file changed between creation and open\"\n        )\n\n\ndef _validate_safe_cache_dir(project_root: Path) -> Path:\n    \"\"\"Create and validate the extension URL download cache one component at a\n    time, refusing symlinked/junctioned components on every supported platform.\"\"\"\n    download_dir = project_root.joinpath(*_CACHE_REL_PARTS)\n    try:\n        if _has_secure_dir_fd():\n            _validate_cache_dir_via_dir_fd(project_root, download_dir)\n        else:\n            _validate_cache_dir_via_paths(project_root, download_dir)\n    except typer.Exit:\n        raise\n    except FileExistsError:\n        console.print(\n            \"[red]Error:[/red] Refusing to use symlinked download cache directory\"","sourceCodeStart":604,"sourceCodeEnd":640,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/extensions/_commands.py#L604-L640","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Treat as a security signal: stop, inspect the cache directory for unexpected symlinks (`find .specify -type l`), and recreate the download cache area fresh","Remove world/group write access to the project and .specify (`chmod -R go-w .specify`) so other users cannot interfere","Pause file-sync/AV tooling that touches .specify during installs, or exclude .specify from sync","Retry the install after cleanup — a transient race from misbehaving tooling clears on a clean run"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"import errno\nfrom specify_cli.extensions import ExtensionError\n\ntry:\n    install_from_url_cmd(project_root, url, speckit_version)\nexcept ExtensionError as e:\n    cause = e.__cause__\n    if isinstance(cause, OSError) and cause.errno == errno.ENOTDIR and \\\n            'changed between creation and open' in cause.strerror:\n        # security race: audit cache dir for symlinks, tighten perms, retry once","preventionTips":["Keep .specify non-writable by other users (chmod go-w)","Exclude .specify from sync/AV tooling that rewrites files in place"],"tags":["extensions","security","toctou","symlink"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}