[Robustness] nvm install: reject a structurally broken installed version

`nvm_is_version_installed` only checks that `bin/node` has the execute bit,
which a zero-byte binary and a dangling `npm` symlink both pass, so a partial
install could be reported as a success — and a broken existing version could
short-circuit `nvm install` as "already installed".

Add `nvm_validate_install`, which requires a non-empty `bin/node` and an `npm`
entry that resolves, and use it in two places: gate the "already installed"
shortcut on it, so a broken version is reinstalled rather than reused, and
re-check it after an install reports success, so a broken result fails loudly
instead of being activated.

It checks layout, not execution: a correctly installed binary can still fail
to run on an incompatible host (e.g. a newer node on an older glibc), which is
not a broken install, and a corrupt download is already rejected by the
checksum check before extraction.
This commit is contained in:
Jordan Harband
2026-07-24 10:51:53 -07:00
parent 72a878447f
commit aee1f83f0f
3 changed files with 177 additions and 2 deletions
@@ -0,0 +1,71 @@
#!/bin/sh
cleanup() {
[ -n "${NVM_DIR}" ] && [ -d "${NVM_DIR}" ] && rm -rf "${NVM_DIR}"
unset -f die cleanup make_healthy make_broken \
nvm_remote_version nvm_has_executable nvm_binary_available \
nvm_install_binary nvm_install_source nvm_use_if_needed \
nvm_install_npm_if_needed nvm_install_default_packages nvm_ensure_default_set
unset NVM_DIR version version_path REINSTALL_MARKER OUTPUT EXIT_CODE
}
die() { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
unset npm_config_prefix NPM_CONFIG_PREFIX
NVM_DIR="$(mktemp -d)"
[ -n "${NVM_DIR}" ] || die 'unable to create temp NVM_DIR'
version='v20.0.0'
version_path="${NVM_DIR}/versions/node/${version}"
REINSTALL_MARKER="${NVM_DIR}/reinstalled"
# Stay offline and keep the post-install activation path inert; only the
# verification behaviour is under test here.
nvm_remote_version() { nvm_echo "${version}"; }
nvm_has_executable() { return 0; }
nvm_binary_available() { return 0; }
nvm_install_source() { return 1; }
nvm_use_if_needed() { return 0; }
nvm_install_npm_if_needed() { return 0; }
nvm_install_default_packages() { return 0; }
nvm_ensure_default_set() { return 0; }
make_healthy() {
mkdir -p "${version_path}/bin"
printf '#!/bin/sh\necho "%s"\n' "${version}" > "${version_path}/bin/node"
chmod +x "${version_path}/bin/node"
}
make_broken() {
mkdir -p "${version_path}/bin"
: > "${version_path}/bin/node" # zero-byte but +x: passes nvm_is_version_installed
chmod +x "${version_path}/bin/node"
}
# 1) An install that reports success but leaves a broken node must fail loudly
# and must not be activated.
rm -rf "${version_path}"
nvm_install_binary() { make_broken; return 0; }
OUTPUT="$(nvm install -b "${version}" 2>&1)"; EXIT_CODE=$?
[ "${EXIT_CODE}" != "0" ] || die "a broken install should fail; got exit 0, output >${OUTPUT}<"
case "${OUTPUT}" in
*'failed verification'*) ;;
*) die "expected a verification-failure message; got >${OUTPUT}<" ;;
esac
# 2) An already-present but broken version must not short-circuit as "already
# installed"; it must be reinstalled.
rm -rf "${version_path}"
make_broken
nvm_install_binary() { command touch "${REINSTALL_MARKER}"; make_healthy; return 0; }
OUTPUT="$(nvm install -b "${version}" 2>&1)"; EXIT_CODE=$?
case "${OUTPUT}" in
*'is already installed'*) die "a broken version should not be treated as already installed: >${OUTPUT}<" ;;
esac
[ -f "${REINSTALL_MARKER}" ] || die 'a broken already-present version was not reinstalled'
[ "${EXIT_CODE}" = "0" ] || die "the healing reinstall should succeed; got ${EXIT_CODE}, output >${OUTPUT}<"
cleanup
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
cleanup() {
[ -n "${NVM_DIR}" ] && [ -d "${NVM_DIR}" ] && rm -rf "${NVM_DIR}"
unset -f die cleanup
unset NVM_DIR version version_path
}
die() { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
type nvm_validate_install > /dev/null 2>&1 || die 'nvm_validate_install is not available'
NVM_DIR="$(mktemp -d)"
[ -n "${NVM_DIR}" ] || die 'Unable to create temporary folder'
version='v20.0.0'
version_path="${NVM_DIR}/versions/node/${version}"
# No version directory at all: not valid.
nvm_validate_install "${version}" 2>/dev/null && die 'a missing version dir should not validate'
# A healthy install: a non-empty node plus an npm symlink that resolves.
# (validation checks layout, not execution, so bin/node need not be a real
# runnable binary here - just non-empty and executable.)
mkdir -p "${version_path}/bin" "${version_path}/lib/node_modules/npm/bin" || die 'setup mkdir failed'
printf '#!/bin/sh\necho "%s"\n' "${version}" > "${version_path}/bin/node"
chmod +x "${version_path}/bin/node"
echo 'npm-cli' > "${version_path}/lib/node_modules/npm/bin/npm-cli.js"
( cd "${version_path}/bin" && ln -s ../lib/node_modules/npm/bin/npm-cli.js npm )
nvm_validate_install "${version}" 2>/dev/null || die 'a healthy install should validate'
# A binary that is correctly installed but cannot run on this host (e.g. a
# newer node on an older glibc) must STILL validate: whether it runs is the
# host's concern, not a broken install.
printf '#!/bin/sh\nexit 1\n' > "${version_path}/bin/node"
chmod +x "${version_path}/bin/node"
nvm_validate_install "${version}" 2>/dev/null || die 'a non-runnable but present binary should still validate'
# A zero-byte but executable node: passes `[ -x ]` but must not validate.
: > "${version_path}/bin/node"
chmod +x "${version_path}/bin/node"
nvm_validate_install "${version}" 2>/dev/null && die 'a zero-byte node should not validate'
# A non-empty node again, but with a dangling npm symlink: must not validate.
printf '#!/bin/sh\necho "%s"\n' "${version}" > "${version_path}/bin/node"
chmod +x "${version_path}/bin/node"
rm -f "${version_path}/lib/node_modules/npm/bin/npm-cli.js"
nvm_validate_install "${version}" 2>/dev/null && die 'a dangling npm symlink should not validate'
cleanup