# 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)