diff --git a/README.md b/README.md index 0593147c..eb564f62 100644 --- a/README.md +++ b/README.md @@ -540,6 +540,8 @@ nvm install --offline 14.7.0 This resolves versions using only locally installed versions and cached downloads. It will not attempt to download anything. This is useful in air-gapped environments, on planes, or when you want to avoid network latency. +A download is only added to the cache (`$NVM_DIR/.cache`) once its checksum has been verified (unless no checksum tool is available, in which case `nvm` warns that it is continuing without verification). `--offline` does not verify cached archives again, so anything placed in the cache by other means is trusted as-is. + You can combine `--offline` with `--lts` to install the latest cached LTS version (as long as LTS aliases have been populated by a prior `nvm ls-remote --lts`): ```sh diff --git a/nvm.sh b/nvm.sh index 1f0f0564..1421d70a 100755 --- a/nvm.sh +++ b/nvm.sh @@ -3868,25 +3868,36 @@ nvm_download_artifact() { nvm_err "Removing the broken local cache..." command rm -rf "${TARBALL}" fi + # `--offline` uses the cached archive without a checksum, so only a verified archive may ever sit at ${TARBALL} + local UNVERIFIED_TARBALL + UNVERIFIED_TARBALL="${TARBALL}.unverified" + command rm -f "${UNVERIFIED_TARBALL}" + nvm_err "Downloading $(nvm_sanitize_url "${TARBALL_URL}")..." # stdout is reserved for the tarball path, so nothing the downloader prints may reach it - nvm_download -L -C - "${PROGRESS_BAR}" "${TARBALL_URL}" -o "${TARBALL}" >&2 || { - command rm -rf "${TARBALL}" "${tmpdir}" + nvm_download -L -C - "${PROGRESS_BAR}" "${TARBALL_URL}" -o "${UNVERIFIED_TARBALL}" >&2 || { + command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}" nvm_err "download from $(nvm_sanitize_url "${TARBALL_URL}") failed" return 4 } - if nvm_grep '404 Not Found' "${TARBALL}" >/dev/null; then - command rm -rf "${TARBALL}" "${tmpdir}" + if nvm_grep '404 Not Found' "${UNVERIFIED_TARBALL}" >/dev/null; then + command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}" nvm_err "HTTP 404 at URL $(nvm_sanitize_url "${TARBALL_URL}")" return 5 fi - nvm_compare_checksum "${TARBALL}" "${CHECKSUM}" || { - command rm -rf "${tmpdir}/files" + nvm_compare_checksum "${UNVERIFIED_TARBALL}" "${CHECKSUM}" || { + command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}" return 6 } + command mv -f "${UNVERIFIED_TARBALL}" "${TARBALL}" || { + command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}" + nvm_err "moving the downloaded archive into the cache at $(nvm_sanitize_path "${TARBALL}") failed" + return 7 + } + nvm_echo "${TARBALL}" } diff --git a/test/fast/Unit tests/nvm_download_artifact only caches verified archives b/test/fast/Unit tests/nvm_download_artifact only caches verified archives new file mode 100755 index 00000000..656d40a9 --- /dev/null +++ b/test/fast/Unit tests/nvm_download_artifact only caches verified archives @@ -0,0 +1,79 @@ +#!/bin/sh + +WORK="${TMPDIR:-/tmp}/nvm_download_artifact_cache.$$" + +cleanup () { + command rm -rf "${WORK}" + unset -f cleanup die download_offline nvm_get_mirror nvm_binary_available nvm_get_download_slug nvm_get_artifact_compression nvm_cache_dir nvm_get_checksum nvm_version_greater_than_or_equal_to nvm_download nvm_compare_checksum +} +die () { echo "$@" ; cleanup ; exit 1; } + +: nvm.sh +\. ../../../nvm.sh + +\. ../../common.sh + +TEST_SLUG='node-v20.0.0-linux-x64' +TEST_CACHE_DIR="${WORK}/.cache/bin/${TEST_SLUG}" +TEST_TARBALL="${TEST_CACHE_DIR}/${TEST_SLUG}.tar.gz" + +nvm_get_mirror() { nvm_echo 'http://example.com'; } +nvm_binary_available() { return 0; } +nvm_get_download_slug() { nvm_echo "${TEST_SLUG}"; } +nvm_get_artifact_compression() { nvm_echo 'tar.gz'; } +nvm_cache_dir() { nvm_echo "${WORK}/.cache"; } +nvm_get_checksum() { nvm_echo 'fake_checksum'; } +nvm_version_greater_than_or_equal_to() { return 0; } + +# appends "downloaded" to the -o target, and records whether that target is the final cache name +nvm_download() { + while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ]; then + if [ "$2" = "${TEST_TARBALL}" ]; then + command touch "${WORK}/downloaded-to-final-name" + fi + nvm_echo 'downloaded' >> "$2" + return 0 + fi + shift + done + return 1 +} + +download_offline() { + NVM_OFFLINE=1 + nvm_download_artifact node binary std v20.0.0 +} + +command mkdir -p "${WORK}" + +# a download with no expected checksum to compare against must not be left in the cache +# (this uses the real `nvm_compare_checksum`, so it runs before that is mocked) +nvm_get_checksum() { nvm_echo ''; } +try nvm_download_artifact node binary std v20.0.0 +[ "${CAPTURED_EXIT_CODE}" = '6' ] || die "empty checksum: expected exit code 6, got ${CAPTURED_EXIT_CODE}" +[ ! -e "${TEST_CACHE_DIR}" ] || die "empty checksum: expected ${TEST_CACHE_DIR} to be removed" +try download_offline +[ "${CAPTURED_EXIT_CODE}" = '4' ] || die "empty checksum: expected --offline to find no cached archive (exit code 4), got ${CAPTURED_EXIT_CODE}" +nvm_get_checksum() { nvm_echo 'fake_checksum'; } + +# a download that fails its checksum must not be left in the cache either +nvm_compare_checksum() { return 1; } +try nvm_download_artifact node binary std v20.0.0 +[ "${CAPTURED_EXIT_CODE}" = '6' ] || die "checksum mismatch: expected exit code 6, got ${CAPTURED_EXIT_CODE}" +[ ! -e "${TEST_CACHE_DIR}" ] || die "checksum mismatch: expected ${TEST_CACHE_DIR} to be removed" +try download_offline +[ "${CAPTURED_EXIT_CODE}" = '4' ] || die "checksum mismatch: expected --offline to find no cached archive (exit code 4), got ${CAPTURED_EXIT_CODE}" + +# a successful download is moved into the cache only once it is verified, replacing any leftover partial download +nvm_compare_checksum() { return 0; } +command mkdir -p "${TEST_CACHE_DIR}" +nvm_echo 'partial' > "${TEST_TARBALL}.unverified" +try nvm_download_artifact node binary std v20.0.0 +[ "${CAPTURED_EXIT_CODE}" = '0' ] || die "verified download: expected exit code 0, got ${CAPTURED_EXIT_CODE}" +[ "${CAPTURED_STDOUT}" = "${TEST_TARBALL}" ] || die "verified download: expected >${TEST_TARBALL}<, got >${CAPTURED_STDOUT}<" +[ "$(command cat "${TEST_TARBALL}")" = 'downloaded' ] || die "verified download: expected the cached archive to hold only the new download, got >$(command cat "${TEST_TARBALL}")<" +[ ! -e "${TEST_TARBALL}.unverified" ] || die 'verified download: expected no unverified download to remain in the cache' +[ ! -e "${WORK}/downloaded-to-final-name" ] || die 'an unverified download was written to the final cache name, where an interrupted download would be left for --offline' + +cleanup