fix(install): pick the .tar.gz Node.js archive when the host has no xz

`tar xf node-*.tar.xz` shells out to the xz binary; minimal Debian, DietPi
and WSL images ship tar without it, so extraction died mid-way and the
installer then failed on a missing directory. Select .tar.xz only when
`xz` is on PATH, in both the installer and the runtime node bootstrap.

Same approach as the earlier #4229 (@JoshuaMart) and #39541 (@karnull);
#11278 (@vominh1919) attempted an apt-only install of xz-utils instead.

Refs #11197
This commit is contained in:
teknium1
2026-09-12 06:23:27 -07:00
committed by Teknium
parent 66dbb656be
commit a7cf8ef8b2
3 changed files with 79 additions and 9 deletions

View File

@@ -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" \

View File

@@ -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" \

View File

@@ -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")