* 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>
764 lines
32 KiB
Python
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)
|