e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
765 lines
32 KiB
Python
765 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)
|