unsloth/tests/studio/install/test_install_node_prebuilt_logic.py
Daniel Han c7c353d740
Pin isolated Node.js installer to committed sha256 digests (#6625)
* Pin isolated Node.js installer to committed sha256 digests

The isolated Node installer verified each downloaded archive only against
SHASUMS256.txt fetched from the same nodejs.org origin as the archive, so a
compromised CDN or TLS path could serve a malicious archive plus a matching
checksum and gain code execution when the extracted node is run during the
npm floor check and version probe.

Anchor trust in studio/node_prebuilt_pins.json, a committed manifest of
per-arch sha256 digests, and verify archives against it. The default channel
installs the pinned version and never fetches the remote SHASUMS. Unpinned
lts, latest, or explicit versions fail closed via UnpinnedNodeRefused unless
UNSLOTH_NODE_ALLOW_UNVERIFIED=1, and the refusal is not swallowed by the
keep-existing-on-transient-failure path. Ship the manifest in package-data.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review nits on the pinned Node installer

- Drop the unused npm_min_major field from node_prebuilt_pins.json; the floor is
  the NPM_MIN_MAJOR module constant and the dead field could silently drift.
- Reword the unpinned-refusal message so it does not tell a user already on the
  default to install it, and point the "add a pin" hint at the exact asset.
- Decode the opt-in SHASUMS body with errors="replace" so a non-UTF8 response
  yields a clean PrebuiltFallback instead of an uncaught UnicodeDecodeError.
- Tests: assert the refusal message (guards the main() catch order, not just the
  exit code), cover malformed-manifest parsing, and drive the opt-in remote-SHASUMS
  path end to end through install_prebuilt.

* Tighten comments in the pinned Node installer

Collapse multi-line rationale comments to single lines, drop docstrings on the
obvious internal helpers (load_pins, pinned_sha256), and shorten the manifest
note. Comments/docstrings only; verified code-unchanged via AST comparison.

* Address Codex review: verify pins on existing installs; tomllib fallback

- existing_install_matches now takes an expected_sha and the short-circuit passes
  the committed pin, so a version-matching but non-pinned or tampered install (e.g.
  from the old remote-SHASUMS path) is re-verified instead of kept. An unpinned
  target without opt-in no longer short-circuits on an existing install; it falls
  through to the UnpinnedNodeRefused fail-closed path.
- The package-data test uses pytest.importorskip(tomllib/tomli) so it does not
  ModuleNotFoundError on the supported 3.9/3.10 interpreters.

* Make the transient-failure keep-existing path pin-aware

The previous commit added the pinned-digest check to the existing-install
short-circuit but not to the post-download-failure fallback, which still kept any
runnable same-version install via existing_install_usable(). A same-version
install whose recorded sha256 is not the pin could therefore be kept on a
transient download failure, the exact artifact the short-circuit rejects. Refuse
to keep a same-version pin-mismatched install there too; a different usable
version is still kept for offline resilience.

* Bump pinned default Node to the current 24 LTS (24.18.0)

Node 24 LTS moved to 24.18.0; since the default channel now resolves straight to
the manifest, a frozen 24.17.0 would downgrade fresh installs and make
UNSLOTH_NODE_VERSION=lts refuse the current LTS as unpinned. Update default_version
and all six per-arch digests (verified against the official SHASUMS256.txt), and
point the test INDEX/short-circuit fixtures at the new LTS.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-24 05:47:58 -07:00

764 lines
32 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Logic tests for studio/install_node_prebuilt.py -- the isolated Node installer.
# No network/GPU: downloads are monkeypatched and archives are built in-memory.
import importlib.util
import io
import json
import os
import sys
import tarfile
import types
import zipfile
from pathlib import Path
import pytest
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_node_prebuilt.py"
SPEC = importlib.util.spec_from_file_location("studio_install_node_prebuilt", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
M = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = M
SPEC.loader.exec_module(M)
HostInfo = M.HostInfo
PrebuiltFallback = M.PrebuiltFallback
def _host(node_os: str, node_arch: str) -> HostInfo:
ext = ".zip" if node_os == "win" else ".tar.gz"
return HostInfo(
system = {"linux": "Linux", "darwin": "Darwin", "win": "Windows"}[node_os],
machine = node_arch,
node_os = node_os,
node_arch = node_arch,
archive_ext = ext,
is_windows = node_os == "win",
)
# ── Host detection (per OS/arch) ──
@pytest.mark.parametrize(
"system,machine,exp_os,exp_arch,exp_ext",
[
("Linux", "x86_64", "linux", "x64", ".tar.gz"),
("Linux", "aarch64", "linux", "arm64", ".tar.gz"),
("Darwin", "x86_64", "darwin", "x64", ".tar.gz"),
("Darwin", "arm64", "darwin", "arm64", ".tar.gz"),
("Windows", "AMD64", "win", "x64", ".zip"),
("Windows", "ARM64", "win", "arm64", ".zip"),
],
)
def test_detect_host(monkeypatch, system, machine, exp_os, exp_arch, exp_ext):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
host = M.detect_host()
assert (host.node_os, host.node_arch, host.archive_ext) == (exp_os, exp_arch, exp_ext)
assert host.is_windows == (exp_os == "win")
@pytest.mark.parametrize(
"system,machine",
[("Plan9", "x86_64"), ("Linux", "sparc64"), ("Linux", "armv7l"), ("Linux", "armhf")],
)
def test_detect_host_unsupported(monkeypatch, system, machine):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
with pytest.raises(PrebuiltFallback):
M.detect_host()
# ── URL / asset construction (pure) ──
def test_asset_and_url_linux():
host = _host("linux", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-linux-x64.tar.gz"
assert (
M.node_download_url("24.17.0", M.node_asset_name("24.17.0", host))
== "https://nodejs.org/dist/v24.17.0/node-v24.17.0-linux-x64.tar.gz"
)
def test_asset_windows_is_zip():
host = _host("win", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-win-x64.zip"
def test_shasums_url():
assert M.node_shasums_url("24.17.0") == "https://nodejs.org/dist/v24.17.0/SHASUMS256.txt"
def test_binary_layout_is_host_aware():
# Windows ships node.exe + node_modules\npm at the root; Unix uses bin/ + lib/.
win = _host("win", "x64")
nix = _host("linux", "x64")
assert M.node_binary_path(Path("/n"), win) == Path("/n/node.exe")
assert M.node_binary_path(Path("/n"), nix) == Path("/n/bin/node")
assert M.npm_cli_path(Path("/n"), win) == Path("/n/node_modules/npm/bin/npm-cli.js")
assert M.npm_cli_path(Path("/n"), nix) == Path("/n/lib/node_modules/npm/bin/npm-cli.js")
# ── SHASUMS256.txt parsing ──
def test_expected_sha256_for():
asset = "node-v24.17.0-linux-x64.tar.gz"
good = "a" * 64
text = (
f"{'b' * 64} node-v24.17.0-linux-arm64.tar.gz\n"
f"{good} {asset}\n"
f"{'c' * 64} node-v24.17.0-win-x64.zip\n"
)
assert M.expected_sha256_for(text, asset) == good
assert M.expected_sha256_for(text, "node-v24.17.0-darwin-x64.tar.gz") is None
def test_expected_sha256_rejects_malformed():
asset = "node-v24.17.0-linux-x64.tar.gz"
assert M.expected_sha256_for(f"notahex {asset}\n", asset) is None
# ── Version selection from index.json ──
INDEX = [
{"version": "v26.3.1", "lts": False},
{"version": "v24.18.0", "lts": "Krypton"},
{"version": "v24.9.0", "lts": "Krypton"},
{"version": "v22.20.0", "lts": "Jod"},
{"version": "v20.19.0", "lts": "Iron"},
]
def test_select_lts_respects_min_major():
# Newest LTS at/above 24 -> 24.18.0 (22.x LTS is below the floor).
assert M.select_node_version(INDEX, channel = "lts", min_major = 24) == "24.18.0"
def test_select_latest_overall():
assert M.select_node_version(INDEX, channel = "latest", min_major = 24) == "26.3.1"
def test_select_explicit_passthrough():
assert M.select_node_version(INDEX, channel = "v24.5.0", min_major = 24) == "24.5.0"
def test_select_no_candidate_raises():
with pytest.raises(PrebuiltFallback):
M.select_node_version(INDEX, channel = "lts", min_major = 99)
# ── Archive extraction (zip + tar.gz with the npm-style symlink), traversal guard ──
def _add_file(
tar: tarfile.TarFile,
name: str,
data: bytes,
mode: int = 0o644,
):
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = mode
tar.addfile(info, io.BytesIO(data))
def _add_symlink(tar: tarfile.TarFile, name: str, target: str):
info = tarfile.TarInfo(name)
info.type = tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
@pytest.mark.skipif(
os.name == "nt",
reason = "Node ships a .zip (no symlinks) on Windows; the tar+symlink path is Unix-only",
)
def test_extract_tar_gz_with_npm_symlink(tmp_path: Path):
# Mirrors the real Node tarball: bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js
archive = tmp_path / "node.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "node-v24/bin/node", b"#!/bin/sh\necho v24.17.0\n", mode = 0o755)
_add_file(tar, "node-v24/lib/node_modules/npm/bin/npm-cli.js", b"// npm")
_add_symlink(tar, "node-v24/bin/npm", "../lib/node_modules/npm/bin/npm-cli.js")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
npm_link = dest / "node-v24" / "bin" / "npm"
assert npm_link.is_symlink()
assert (dest / "node-v24" / "bin" / "node").exists()
# executable bit preserved
assert (dest / "node-v24" / "bin" / "node").stat().st_mode & 0o111
def test_extract_zip(tmp_path: Path):
archive = tmp_path / "node.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("node-v24-win-x64/node.exe", b"MZ")
zf.writestr("node-v24-win-x64/npm.cmd", b"@echo off")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
assert (dest / "node-v24-win-x64" / "node.exe").exists()
def test_extract_rejects_path_traversal(tmp_path: Path):
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "../escape.txt", b"pwn")
with pytest.raises(PrebuiltFallback):
M.extract_archive(archive, tmp_path / "out")
# ── Checksum-verified download (accept + reject) ──
def test_download_file_verified_accepts_match(tmp_path: Path, monkeypatch):
payload = b"real-node-archive"
sha = M.hashlib.sha256(payload).hexdigest()
def fake_download(url: str, destination: Path):
destination.write_bytes(payload)
monkeypatch.setattr(M, "download_file", fake_download)
dest = tmp_path / "a.tar.gz"
M.download_file_verified("http://x/a.tar.gz", dest, expected_sha256 = sha, label = "a")
assert dest.read_bytes() == payload
def test_download_file_verified_rejects_mismatch(tmp_path: Path, monkeypatch):
def fake_download(url: str, destination: Path):
destination.write_bytes(b"tampered")
monkeypatch.setattr(M, "download_file", fake_download)
with pytest.raises(PrebuiltFallback):
M.download_file_verified("http://x/a", tmp_path / "a", expected_sha256 = "0" * 64, label = "a")
# ── Lock liveness probe (Windows must not use os.kill(pid, 0)) ──
def test_pid_is_alive_windows_uses_tasklist_not_os_kill(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def fail_kill(pid, sig):
raise AssertionError("Windows liveness must not call os.kill(pid, 0)")
def fake_run(cmd, **kwargs):
assert cmd[:2] == ["tasklist", "/FI"]
assert "PID eq 1234" in cmd
return types.SimpleNamespace(stdout = '"node.exe","1234","Console","1","12,345 K"\n')
monkeypatch.setattr(M.os, "kill", fail_kill)
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_windows_false_when_tasklist_omits_pid(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
monkeypatch.setattr(
M.subprocess,
"run",
lambda *a, **k: types.SimpleNamespace(
stdout = "INFO: No tasks are running which match the specified criteria.\n"
),
)
assert M._pid_is_alive(1234) is False
def test_pid_is_alive_windows_assumes_alive_when_tasklist_fails(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def boom(*args, **kwargs):
raise OSError("tasklist unavailable")
monkeypatch.setattr(M.subprocess, "run", boom)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_posix_signal_zero(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "linux")
calls = []
def fake_kill(pid, sig):
calls.append((pid, sig))
if pid == 9999:
raise ProcessLookupError
monkeypatch.setattr(M.os, "kill", fake_kill)
assert M._pid_is_alive(1234) is True
assert M._pid_is_alive(9999) is False
assert calls == [(1234, 0), (9999, 0)]
# ── existing_install_matches + install_prebuilt short-circuit ──
def test_existing_install_matches_false_without_metadata(tmp_path: Path):
host = _host("linux", "x64")
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_existing_install_matches_true_when_version_and_runtime_ok(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True
# npm too old -> not a match
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_install_prebuilt_short_circuits_when_version_matches(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
version = M.pinned_default_version(M.load_pins()) # == INDEX's newest LTS
asset = M.node_asset_name(version, _host("linux", "x64"))
pin = M.pinned_sha256(M.load_pins(), version, asset) # short-circuit now needs the pin
M.write_metadata(install_dir, version = version, asset = asset, sha256 = pin)
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: version)
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
def boom(*a, **k):
raise AssertionError("must not download when the install already matches")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_existing_install_usable_is_version_agnostic(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
assert M.existing_install_usable(tmp_path, host) is False # no metadata
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_usable(tmp_path, host) is True
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_usable(tmp_path, host) is False # npm below floor
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: None)
assert M.existing_install_usable(tmp_path, host) is False # node does not run
def _offline(*a, **k):
raise OSError("nodejs.org unreachable")
def test_install_prebuilt_keeps_existing_when_index_unreachable(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
def boom(*a, **k):
raise AssertionError("must not download when keeping the existing install")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_when_index_unreachable_and_no_install(
tmp_path: Path, monkeypatch
):
install_dir = tmp_path / "node" # nothing on disk to fall back to
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
def test_install_prebuilt_force_does_not_keep_existing_offline(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = True)
@pytest.mark.parametrize(
"ver,ok",
[
("20.19.0", True),
("20.18.9", False),
("22.12.0", True),
("22.11.5", False),
("23.0.0", True),
("24.4.1", True),
("21.7.3", False),
("24", True),
("20", False),
],
)
def test_meets_node_floor(ver, ok):
assert M._meets_node_floor(ver) is ok
def test_install_prebuilt_rejects_explicit_below_floor(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
def boom(*a, **k):
raise AssertionError("must not download a below-floor Node")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
with pytest.raises(PrebuiltFallback):
M.install_prebuilt(install_dir, channel = "20.18.0", min_major = 24, force = False)
def test_install_prebuilt_keeps_existing_when_download_fails(tmp_path: Path, monkeypatch):
# Archive download fails but a usable older Node is on disk -> keep it.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # newest LTS = 24.18.0 (pinned)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "download_file_verified", _offline) # archive download fails
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_download_failure_without_existing(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node" # nothing usable on disk
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "download_file_verified", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
# ── Isolation invariant: the installer only writes inside its own install_dir ──
def test_run_node_pins_npm_prefix_to_install_dir(tmp_path: Path, monkeypatch):
# Every node/npm call the installer makes redirects npm's global prefix into
# the isolated install_dir and drops an inherited NODE_PATH, so a stray `npm
# -g` can never write to the user's system Node/npm.
install_dir = tmp_path / "node"
monkeypatch.setenv("NPM_CONFIG_PREFIX", "/usr/local") # user's own global prefix
monkeypatch.setenv("NODE_PATH", "/usr/lib/node_modules")
captured = {}
def fake_run(cmd, **kw):
captured["env"] = kw["env"]
return types.SimpleNamespace(returncode = 0, stdout = "v24.17.0\n", stderr = "")
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._run_node(install_dir, _host("linux", "x64"), ["-v"]) == "v24.17.0"
env = captured["env"]
assert env["NPM_CONFIG_PREFIX"] == str(install_dir)
assert env["npm_config_prefix"] == str(install_dir)
assert "NODE_PATH" not in env # inherited NODE_PATH is dropped, not leaked in
def test_ensure_npm_floor_scopes_upgrade_to_install_dir(tmp_path: Path, monkeypatch):
# A pinned build shipping npm < 11 self-upgrades, but only inside the isolated
# prefix: it goes through _run_node against install_dir, never the system.
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
calls = []
monkeypatch.setattr(M, "_run_node", lambda d, h, args, **kw: calls.append((d, args)) or "")
M._ensure_npm_floor(install_dir, _host("linux", "x64"))
assert len(calls) == 1
target_dir, args = calls[0]
assert target_dir == install_dir # upgrade scoped to the isolated dir
assert args[-3:] == ["install", "-g", f"npm@^{M.NPM_MIN_MAJOR}"]
def test_ensure_npm_floor_noop_when_npm_meets_bar(tmp_path: Path, monkeypatch):
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: M.NPM_MIN_MAJOR)
def boom(*a, **k):
raise AssertionError("must not run an npm upgrade when npm already meets the floor")
monkeypatch.setattr(M, "_run_node", boom)
M._ensure_npm_floor(tmp_path / "node", _host("linux", "x64"))
# ── Pinned digest manifest (trust anchor) ──
# Archives must be verified against committed pins, never a same-origin re-fetch.
ALL_SUPPORTED_HOSTS = [
("linux", "x64"),
("linux", "arm64"),
("darwin", "x64"),
("darwin", "arm64"),
("win", "x64"),
("win", "arm64"),
]
def test_load_pins_exposes_valid_default_version():
pins = M.load_pins()
version = M.pinned_default_version(pins)
assert M._version_tuple(version) # parses as a real version
assert M._meets_node_floor(version) # the pinned default clears the build floor
def test_pinned_manifest_covers_every_supported_asset():
# A future Node bump must pin all six os/arch archives or a host loses its anchor.
pins = M.load_pins()
version = M.pinned_default_version(pins)
for node_os, node_arch in ALL_SUPPORTED_HOSTS:
host = _host(node_os, node_arch)
asset = M.node_asset_name(version, host)
digest = M.pinned_sha256(pins, version, asset)
assert digest is not None and len(digest) == 64, f"missing pin for {asset}"
assert all(c in "0123456789abcdef" for c in digest)
def test_pinned_sha256_unknown_pair_returns_none():
pins = M.load_pins()
assert M.pinned_sha256(pins, "99.0.0", "node-v99.0.0-linux-x64.tar.gz") is None
version = M.pinned_default_version(pins)
assert M.pinned_sha256(pins, version, "node-vX-bogus-arch.tar.gz") is None
def test_load_pins_rejects_bad_schema(tmp_path: Path, monkeypatch):
bad = tmp_path / "node_prebuilt_pins.json"
bad.write_text(json.dumps({"schema_version": 999}))
monkeypatch.setattr(M, "pins_path", lambda: bad)
with pytest.raises(PrebuiltFallback):
M.load_pins()
def test_load_pins_raises_when_missing(tmp_path: Path, monkeypatch):
monkeypatch.setattr(M, "pins_path", lambda: tmp_path / "does_not_exist.json")
with pytest.raises(PrebuiltFallback):
M.load_pins()
def test_resolve_expected_sha256_uses_pin_without_touching_network(monkeypatch):
pins = M.load_pins()
version = M.pinned_default_version(pins)
asset = M.node_asset_name(version, _host("linux", "x64"))
def boom(*a, **k):
raise AssertionError("pinned path must not fetch the remote SHASUMS256.txt")
monkeypatch.setattr(M, "download_bytes", boom)
sha = M.resolve_expected_sha256(pins, version, asset, allow_unverified = False)
assert sha == M.pinned_sha256(pins, version, asset)
def test_resolve_expected_sha256_failcloses_on_unpinned(monkeypatch):
pins = M.load_pins()
def boom(*a, **k):
raise AssertionError("must not reach the network when refusing an unpinned version")
monkeypatch.setattr(M, "download_bytes", boom)
with pytest.raises(PrebuiltFallback):
M.resolve_expected_sha256(
pins, "26.3.1", "node-v26.3.1-linux-x64.tar.gz", allow_unverified = False
)
def test_resolve_expected_sha256_optin_falls_back_to_remote_shasums(monkeypatch):
pins = M.load_pins()
asset = "node-v26.3.1-linux-x64.tar.gz"
remote_sha = "d" * 64
monkeypatch.setattr(M, "download_bytes", lambda url, **k: f"{remote_sha} {asset}\n".encode())
# Only with the explicit opt-in does the legacy remote-checksum path run.
sha = M.resolve_expected_sha256(pins, "26.3.1", asset, allow_unverified = True)
assert sha == remote_sha
@pytest.mark.parametrize(
"value,expected",
[("1", True), ("true", True), ("YES", True), ("on", True), ("0", False), ("", False)],
)
def test_allow_unverified_node_reads_env(monkeypatch, value, expected):
monkeypatch.setenv(M.ALLOW_UNVERIFIED_ENV, value)
assert M.allow_unverified_node() is expected
def test_install_prebuilt_default_channel_resolves_pinned_version(tmp_path: Path, monkeypatch):
# Default channel installs the pinned version with no index.json round-trip.
pins = M.load_pins()
version = M.pinned_default_version(pins)
install_dir = tmp_path / "node"
install_dir.mkdir()
asset = M.node_asset_name(version, _host("linux", "x64"))
pin = M.pinned_sha256(pins, version, asset) # kept only if the recorded digest is the pin
M.write_metadata(install_dir, version = version, asset = asset, sha256 = pin)
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: version)
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
def boom(*a, **k):
raise AssertionError("default channel must not hit nodejs.org when the install matches")
monkeypatch.setattr(M, "fetch_json", boom) # no index.json
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "pinned", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_failcloses_on_unpinned_latest(tmp_path: Path, monkeypatch):
# Unpinned `latest`, no opt-in, nothing on disk to keep: refuse.
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # latest overall = 26.3.1 (unpinned)
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
def boom(*a, **k):
raise AssertionError("must not download an unpinned archive")
monkeypatch.setattr(M, "download_file", boom)
with pytest.raises(M.UnpinnedNodeRefused):
M.install_prebuilt(install_dir, channel = "latest", min_major = 24, force = False)
@pytest.mark.parametrize("channel", ["latest", "26.3.1"])
def test_install_prebuilt_unpinned_refusal_does_not_keep_existing(
tmp_path: Path, monkeypatch, channel
):
# Regression: an unpinned refusal must fail closed even with a usable install on
# disk; the keep-existing fallback is for transient failures only.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "old", sha256 = "old")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # latest overall = 26.3.1 (unpinned)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11) # existing install is usable
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
def boom(*a, **k):
raise AssertionError("must not download an unpinned archive")
monkeypatch.setattr(M, "download_file", boom)
with pytest.raises(M.UnpinnedNodeRefused):
M.install_prebuilt(install_dir, channel = channel, min_major = 24, force = False)
def test_unpinned_refusal_maps_to_fallback_exit_code(tmp_path: Path, monkeypatch, capsys):
# main() must surface the refusal as EXIT_FALLBACK (setup treats it as a failed
# install with guidance), not as a success masked by the keep-existing path.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "old", sha256 = "old")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
rc = M.main(["--install-dir", str(install_dir), "--node-version", "latest"])
assert rc == M.EXIT_FALLBACK
# Guard the main() catch order: UnpinnedNodeRefused must be caught before the
# generic PrebuiltFallback, so assert the message, not just the exit code.
out = capsys.readouterr().out
assert "refusing to install Node" in out
assert "prebuilt unavailable" not in out
def test_resolve_expected_sha256_rejects_malformed_pins():
# A structurally-broken manifest is "not pinned" (None), never a bogus digest.
asset = "node-v24.17.0-linux-x64.tar.gz"
assert M.pinned_sha256({"versions": "nope"}, "24.17.0", asset) is None
assert M.pinned_sha256({"versions": {"24.17.0": "nope"}}, "24.17.0", asset) is None
assert M.pinned_sha256({"versions": {"24.17.0": {asset: "x" * 63}}}, "24.17.0", asset) is None
assert M.pinned_sha256({"versions": {"24.17.0": {asset: "z" * 64}}}, "24.17.0", asset) is None
# an uppercase but otherwise valid digest is normalized to lowercase
up = "A" * 64
assert M.pinned_sha256({"versions": {"24.17.0": {asset: up}}}, "24.17.0", asset) == up.lower()
for bad in [{}, {"default_version": ""}, {"default_version": "not-a-version"}]:
with pytest.raises(PrebuiltFallback):
M.pinned_default_version(bad)
def test_install_prebuilt_optin_takes_remote_shasums_path(tmp_path: Path, monkeypatch):
# With the opt-in set, an unpinned version drives the remote-SHASUMS path end to
# end: fetch SHASUMS256.txt, then the verified archive download (no refusal).
install_dir = tmp_path / "node" # nothing on disk -> errors re-raise, not keep-existing
asset = "node-v26.3.1-linux-x64.tar.gz"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # latest = 26.3.1 (unpinned)
monkeypatch.setenv(M.ALLOW_UNVERIFIED_ENV, "1")
shasums_fetched = {"n": 0}
def fake_shasums(url, **k):
shasums_fetched["n"] += 1
return f"{'d' * 64} {asset}\n".encode()
class _ReachedDownload(Exception):
pass
def reached(*a, **k):
raise _ReachedDownload
monkeypatch.setattr(M, "download_bytes", fake_shasums)
monkeypatch.setattr(M, "download_file_verified", reached)
with pytest.raises(_ReachedDownload):
M.install_prebuilt(install_dir, channel = "latest", min_major = 24, force = False)
assert shasums_fetched["n"] == 1 # the opt-in path fetched the remote SHASUMS
def test_pins_manifest_ships_next_to_installer():
# The committed manifest must sit beside the installer so __file__ resolution finds it.
assert M.pins_path() == MODULE_PATH.parent / M.PINS_FILENAME
assert M.pins_path().is_file()
def test_pins_manifest_is_declared_in_package_data():
# An unpackaged trust anchor is no trust anchor: a pip install must ship it.
# tomllib is stdlib only on 3.11+; fall back to tomli, else skip on 3.9/3.10.
tomllib = pytest.importorskip("tomllib" if sys.version_info >= (3, 11) else "tomli")
data = tomllib.loads((PACKAGE_ROOT / "pyproject.toml").read_text(encoding = "utf-8"))
studio_globs = data["tool"]["setuptools"]["package-data"]["studio"]
assert M.PINS_FILENAME in studio_globs
def test_existing_install_matches_enforces_expected_sha(tmp_path: Path, monkeypatch):
# The short-circuit must not keep a version-matching install whose recorded digest
# is not the pin (old remote-SHASUMS install or a tampered artifact).
host = _host("linux", "x64")
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "aa")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True # back-compat
assert M.existing_install_matches(tmp_path, host, version = "24.17.0", expected_sha = "aa") is True
assert M.existing_install_matches(tmp_path, host, version = "24.17.0", expected_sha = "bb") is False
def test_install_prebuilt_refuses_existing_unpinned_install(tmp_path: Path, monkeypatch):
# Codex P2: an unpinned version already on disk must still fail closed without the
# opt-in, not be kept by the version-only short-circuit.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "26.3.1", asset = "a", sha256 = "s")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "26.3.1")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.delenv(M.ALLOW_UNVERIFIED_ENV, raising = False)
def boom(*a, **k):
raise AssertionError("must not keep or download an unpinned install")
monkeypatch.setattr(M, "download_file", boom)
with pytest.raises(M.UnpinnedNodeRefused):
M.install_prebuilt(install_dir, channel = "26.3.1", min_major = 24, force = False)
def test_pinned_target_wrong_sha_not_kept_when_download_fails(tmp_path: Path, monkeypatch):
# Symmetry with the short-circuit guard: the transient-failure fallback must not
# keep a same-version install whose recorded digest is not the pin. (A different
# usable version is still kept for offline resilience -- covered above.)
host = _host("linux", "x64")
version = M.pinned_default_version(M.load_pins())
asset = M.node_asset_name(version, host)
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = version, asset = asset, sha256 = "0" * 64) # not the pin
monkeypatch.setattr(M, "detect_host", lambda: host)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: version)
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "download_file_verified", _offline) # transient download failure
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "pinned", min_major = 24, force = False)