diff --git a/scripts/install.sh b/scripts/install.sh index d87d3beac0..0b189f7fa2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1042,12 +1042,18 @@ install_node_line() { # Resolve the latest v${node_line}.x.x tarball name from the index page local index_url="https://nodejs.org/dist/latest-v${node_line}.x/" - local tarball_name - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ - | head -1) + local tarball_name="" + # `tar xf` shells out to xz for .tar.xz; minimal Debian/DietPi/WSL images ship tar without it + # and the extract dies mid-way ("xz: Cannot exec"). Only pick .tar.xz when xz is present (#11197). + if command -v xz >/dev/null 2>&1; then + tarball_name=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ + | head -1) + else + log_info "xz not found — using the .tar.gz Node.js archive" + fi - # Fallback to .tar.gz if .tar.xz not available + # Fallback to .tar.gz if .tar.xz not available (or xz is missing) if [ -z "$tarball_name" ]; then tarball_name=$(curl -fsSL "$index_url" \ | grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \ diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 05aaf3f466..c4ff94a02b 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -271,10 +271,13 @@ _nb_install_bundled_node() { esac local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/" - local tarball - tarball=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ - | head -1) + local tarball="" + # `tar xf` needs the xz binary for .tar.xz; minimal images ship tar without it (#11197). + if command -v xz >/dev/null 2>&1; then + tarball=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ + | head -1) + fi if [ -z "$tarball" ]; then tarball=$(curl -fsSL "$index_url" \ | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \ diff --git a/tests/test_install_sh_node_tarball_without_xz.py b/tests/test_install_sh_node_tarball_without_xz.py new file mode 100644 index 0000000000..468306acc9 --- /dev/null +++ b/tests/test_install_sh_node_tarball_without_xz.py @@ -0,0 +1,61 @@ +"""Node.js download picks a .tar.gz when the host has no ``xz`` (#11197). + +`tar xf node-*.tar.xz` shells out to the xz binary; minimal Debian/DietPi/WSL images ship tar +without it, so the extract died mid-way and the installer then failed on a missing directory. +Both tarball selectors (installer and runtime bootstrap) are driven for real against a stubbed +index page; only the ``xz`` binary's presence differs between the two arms. +""" +from __future__ import annotations + +import os +import re +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +INDEX = "node-v26.7.0-linux-x64.tar.xz\nnode-v26.7.0-linux-x64.tar.gz\n" + + +def _function(path: Path, name: str) -> str: + match = re.search(rf"^{re.escape(name)}\(\) \{{\n.*?^\}}\n", path.read_text(encoding="utf-8"), re.M | re.S) + assert match, f"{name}() not found in {path}" + return match.group(0) + + +def _selected_tarball(tmp_path: Path, *, with_xz: bool, script: Path, fn: str, call: str) -> str: + """Run the real tarball-selection function with curl stubbed to serve INDEX; the download step + records the chosen name and aborts, so nothing is extracted.""" + bin_dir = tmp_path / ("bin-xz" if with_xz else "bin-noxz") + bin_dir.mkdir() + curl = bin_dir / "curl" + curl.write_text("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = -o ] && { echo \"$4\" >> \"$PICKED\"; exit 1; }; done\n" + f"printf '%s' '{INDEX}'\n", encoding="utf-8") + curl.chmod(curl.stat().st_mode | stat.S_IXUSR) + # PATH holds only this dir: the host's real xz must not leak into the "no xz" arm. + for tool in ("grep", "head", "mktemp", "rm", "uname", "sh", "printf"): + real = shutil.which(tool) + if real: + os.symlink(real, bin_dir / tool) + if with_xz: + (bin_dir / "xz").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (bin_dir / "xz").chmod(0o755) + picked = tmp_path / f"picked-{with_xz}-{fn}" + harness = ("log_info() { :; }; log_warn() { :; }; _nb_log() { :; }; _nb_warn() { :; }\n" + "HERMES_NODE_TARGET_MAJOR=26\n" + _function(script, fn) + call) + env = {"PATH": str(bin_dir), "PICKED": str(picked), "HOME": str(tmp_path)} + subprocess.run([shutil.which("bash") or "/bin/bash", "-c", harness], env=env, check=False, capture_output=True) + return picked.read_text(encoding="utf-8").strip() if picked.exists() else "" + + +@pytest.mark.linux_only +@pytest.mark.parametrize("script, fn, call", [ + (REPO_ROOT / "scripts" / "install.sh", "install_node_line", "\ninstall_node_line 26 linux x64\n"), + (REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh", "_nb_install_bundled_node", "\n_nb_install_bundled_node\n"), +]) +def test_tarball_format_follows_xz_availability(tmp_path: Path, script: Path, fn: str, call: str) -> None: + assert _selected_tarball(tmp_path, with_xz=True, script=script, fn=fn, call=call).endswith(".tar.xz") + assert _selected_tarball(tmp_path, with_xz=False, script=script, fn=fn, call=call).endswith(".tar.gz")