diff --git a/.codex/workflows/release-changelog.md b/.codex/workflows/release-changelog.md index 0fdeed074b..15bca363a6 100644 --- a/.codex/workflows/release-changelog.md +++ b/.codex/workflows/release-changelog.md @@ -141,6 +141,18 @@ the tag name, but the tag/`version.json` consistency is also worth checking at the source-tree level before any artifact is built. Run this immediately after the tag is pushed: +Release artifacts are packaged with stable timestamps and sorted member lists, +and the release workflow compares the final archive member list against the +expected publish output before upload. If that validation fails, fix the +packaging step and re-run the failed release lane instead of uploading the +archive manually. + +Each release archive also contains `MANIFEST.sha256`, generated from the +published payload before upload. `install.sh` verifies that manifest after +extraction for releases that require it, so do not remove or hand-edit it when +diagnosing release artifacts. Older explicit-version installs may not contain +the manifest and fall back to archive-level checksum verification. + ```bash git show "v1.17.0:version.json" | grep -q '"version": "1.17.0"' \ && echo "version.json OK" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f0d9d9ed6..58730f3617 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -277,14 +277,63 @@ jobs: run: | mkdir -p artifacts cd publish - tar czf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" . + find . -exec touch -t 200001010000 {} + + if command -v sha256sum >/dev/null 2>&1; then + hash_file() { sha256sum "$1"; } + else + hash_file() { shasum -a 256 "$1"; } + fi + find . -type f ! -name MANIFEST.sha256 ! -name .MANIFEST.sha256.tmp | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do + hash_file "$file" + done > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp MANIFEST.sha256 + touch -t 200001010000 MANIFEST.sha256 + find . -type f | sed 's#^\./##' | LC_ALL=C sort > "../artifacts/CodeIndex-${{ matrix.rid }}.members" + tar czf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" -T "../artifacts/CodeIndex-${{ matrix.rid }}.members" + tar tzf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" | LC_ALL=C sort > "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members" + cmp "../artifacts/CodeIndex-${{ matrix.rid }}.members" "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members" + rm "../artifacts/CodeIndex-${{ matrix.rid }}.members" "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members" - name: Archive release artifacts (Windows) if: runner.os == 'Windows' shell: pwsh run: | New-Item -ItemType Directory -Force -Path artifacts - Compress-Archive -Path publish\* -DestinationPath "artifacts\CodeIndex-${{ matrix.rid }}.zip" + $fixedTimestamp = [DateTime]'2000-01-01T00:00:00Z' + Get-ChildItem publish -Recurse | ForEach-Object { $_.LastWriteTimeUtc = $fixedTimestamp } + $files = Get-ChildItem publish -File -Recurse | + Where-Object { $_.Name -ne 'MANIFEST.sha256' -and $_.Name -ne '.MANIFEST.sha256.tmp' } | + Sort-Object FullName + $manifestLines = foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath((Resolve-Path publish), $file.FullName).Replace('\', '/') + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant() + "$hash $relative" + } + $manifestLines | Set-Content -NoNewline:$false -Encoding ascii publish\MANIFEST.sha256 + (Get-Item publish\MANIFEST.sha256).LastWriteTimeUtc = $fixedTimestamp + $files = Get-ChildItem publish -File -Recurse | Sort-Object FullName + $relativeFiles = $files | ForEach-Object { [System.IO.Path]::GetRelativePath((Resolve-Path publish), $_.FullName) } | Sort-Object + Push-Location publish + try { + Compress-Archive -Path $relativeFiles -DestinationPath "..\artifacts\CodeIndex-${{ matrix.rid }}.zip" + } finally { + Pop-Location + } + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path "artifacts\CodeIndex-${{ matrix.rid }}.zip")) + try { + $actual = $zip.Entries | Where-Object { $_.Name } | ForEach-Object { $_.FullName } | Sort-Object + $expected = $relativeFiles | ForEach-Object { $_.Replace('\', '/') } | Sort-Object + if (@($actual).Count -ne @($expected).Count) { + throw "Archive member count mismatch: expected $(@($expected).Count), got $(@($actual).Count)" + } + for ($i = 0; $i -lt @($expected).Count; $i++) { + if (@($actual)[$i] -ne @($expected)[$i]) { + throw "Archive member mismatch at index ${i}: expected '$(@($expected)[$i])', got '$(@($actual)[$i])'" + } + } + } finally { + $zip.Dispose() + } - name: Upload release artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/changelog.d/unreleased/2012.fixed.md b/changelog.d/unreleased/2012.fixed.md new file mode 100644 index 0000000000..4b5a85a7dc --- /dev/null +++ b/changelog.d/unreleased/2012.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 2012 +affected: + - install.sh +--- + +## English + +- **Installer reuse now requires a completed integrity marker (#2012)** — `install.sh` writes `integrity_ok` only after staging validated runtime assets, and existing installs without that marker are treated as incomplete. + +## 日本語 + +- **installer の再利用判定が完了済み integrity marker を必須にしました (#2012)** — `install.sh` は検証済み runtime asset の staging 後にだけ `integrity_ok` を書き込み、この marker がない既存 install は未完了として扱います。 diff --git a/changelog.d/unreleased/2040.fixed.md b/changelog.d/unreleased/2040.fixed.md new file mode 100644 index 0000000000..d529b1131e --- /dev/null +++ b/changelog.d/unreleased/2040.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2040 +affected: + - .github/workflows/release.yml + - .codex/workflows/release-changelog.md + - install.sh +--- + +## English + +- **Installer extraction now verifies per-file release payload checksums (#2040)** — release archives include `MANIFEST.sha256`, and `install.sh` refuses to install if any extracted payload file is missing or has a mismatched digest. + +## 日本語 + +- **installer の展開処理が release payload のファイル別 checksum を検証するようになりました (#2040)** — release archive に `MANIFEST.sha256` を含め、`install.sh` は展開後の payload file が欠落または digest 不一致の場合に install を拒否します。 diff --git a/changelog.d/unreleased/2041.fixed.md b/changelog.d/unreleased/2041.fixed.md new file mode 100644 index 0000000000..8217e40d1c --- /dev/null +++ b/changelog.d/unreleased/2041.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2041 +affected: + - .github/workflows/release.yml + - .codex/workflows/release-changelog.md +--- + +## English + +- **Release archives now validate stable member lists (#2041)** — release packaging normalizes artifact timestamps, writes archive members in sorted order, and compares the final archive listing before upload. + +## 日本語 + +- **リリースアーカイブの安定した member list を検証するようになりました (#2041)** — release packaging は成果物の timestamp を正規化し、archive member をソート順で書き込み、upload 前に最終的な archive listing を比較します。 diff --git a/install.sh b/install.sh index 3334b19cea..cd30c8960d 100755 --- a/install.sh +++ b/install.sh @@ -66,6 +66,7 @@ set -euo pipefail REPO="Widthdom/CodeIndex" INSTALL_DIR="${CDIDX_INSTALL_DIR:-$HOME/.local/bin}" BINARY_NAME="cdidx" +MANIFEST_REQUIRED_VERSION="1.24.6" GITHUB_BASE_URL="${CDIDX_GITHUB_BASE_URL:-https://github.com}" GITHUB_API_BASE_URL="${CDIDX_GITHUB_API_BASE_URL:-https://api.github.com}" # Normalize optional base URL overrides by removing a trailing slash. @@ -198,6 +199,29 @@ strip_version_prefix() { printf '%s' "$1" | sed 's/^[^0-9]*//' } +semver_core() { + printf '%s' "$1" | sed 's/^[^0-9]*//' | sed 's/[^0-9.].*$//' +} + +semver_ge() { + local left right + left="$(semver_core "$1")" + right="$(semver_core "$2")" + + awk -v left="$left" -v right="$right" ' + BEGIN { + split(left, l, ".") + split(right, r, ".") + for (i = 1; i <= 3; i++) { + li = (l[i] == "" ? 0 : l[i]) + 0 + ri = (r[i] == "" ? 0 : r[i]) + 0 + if (li > ri) exit 0 + if (li < ri) exit 1 + } + exit 0 + }' +} + extract_release_tag_name() { local api_response="$1" local version="" @@ -490,6 +514,9 @@ existing_install_is_reusable() { if [ ! -f "${INSTALL_DIR}/version.json" ]; then return 1 fi + if ! grep -Eq '"integrity_ok"[[:space:]]*:[[:space:]]*true' "${INSTALL_DIR}/version.json"; then + return 1 + fi [ -f "${INSTALL_DIR}/LICENSE" ] || return 1 [ -f "${INSTALL_DIR}/COMMERCIAL_LICENSE.md" ] || return 1 @@ -510,6 +537,99 @@ existing_install_is_reusable() { return 0 } +calculate_sha256() { + local path="$1" + + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + elif command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + elif command -v openssl > /dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{print $NF}' + else + error "No checksum tool found (need sha256sum, shasum, or openssl). Cannot verify release payload integrity." + fi +} + +validate_archive_members() { + local archive="$1" + local member + + tar tzf "$archive" | while IFS= read -r member || [ -n "$member" ]; do + case "$member" in + ""|/*|..|../*|*/../*|*/.. ) + error "Release archive contains unsafe member path before extraction: ${member:-}" + ;; + esac + done +} + +verify_payload_manifest() { + local extract_dir="$1" + local manifest="${extract_dir}/MANIFEST.sha256" + local manifest_paths line expected path actual extracted_paths + + if [ ! -f "$manifest" ]; then + if semver_ge "${VERSION#v}" "$MANIFEST_REQUIRED_VERSION"; then + error "Release payload is missing MANIFEST.sha256. Refusing to install without per-file integrity metadata." + fi + + warn "Release payload is missing MANIFEST.sha256; falling back to archive-level checksum verification for legacy release ${VERSION}." + return 0 + fi + + if ! manifest_paths="$(mktemp)"; then + error "Failed to create temporary manifest path list." + fi + if ! extracted_paths="$(mktemp)"; then + rm -f "$manifest_paths" + error "Failed to create temporary extracted path list." + fi + + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + expected="${line%% *}" + path="${line#* }" + case "$path" in + ""|/*|*"/../"*|../*|*"/.." ) + error "Invalid path in release payload manifest: ${path}" + ;; + esac + printf '%s\n' "$path" >> "$manifest_paths" + if [ ! -f "${extract_dir}/${path}" ]; then + rm -f "$manifest_paths" "$extracted_paths" + error "Release payload manifest entry missing after extraction: ${path}" + fi + actual="$(calculate_sha256 "${extract_dir}/${path}")" + if [ "$actual" != "$expected" ]; then + rm -f "$manifest_paths" "$extracted_paths" + error "Release payload checksum mismatch for ${path}.\n Expected: ${expected}\n Actual: ${actual}" + fi + done < "$manifest" + + ( + cd "$extract_dir" + find . -type f ! -name MANIFEST.sha256 | sed 's#^\./##' | LC_ALL=C sort + ) > "$extracted_paths" + + while IFS= read -r path || [ -n "$path" ]; do + [ -n "$path" ] || continue + if ! grep -Fxq "$path" "$manifest_paths"; then + rm -f "$manifest_paths" "$extracted_paths" + error "Release payload contains file not listed in MANIFEST.sha256: ${path}" + fi + done < "$extracted_paths" + + rm -f "$manifest_paths" "$extracted_paths" +} + +write_integrity_version_json() { + local target="$1" + local version="${VERSION#v}" + + printf '{"version":"%s","integrity_ok":true}\n' "$version" > "$target" +} + restore_backed_up_files() { local backup_dir="$1" local install_dir="$2" @@ -555,7 +675,7 @@ promote_staged_install() { local backed_up_files="" local promoted_files="" - for asset in $required_files; do + for asset in ${BINARY_NAME} $required_assets; do if [ -e "${install_dir}/${asset}" ]; then if ! mv "${install_dir}/${asset}" "${backup_dir}/${asset}"; then report_error "Failed to stage existing ${asset} into backup at ${backup_dir}. Install aborted before replacing the current install." @@ -787,15 +907,7 @@ download_and_install() { fi local actual_checksum - if command -v sha256sum > /dev/null 2>&1; then - actual_checksum="$(sha256sum "${tmpdir}/${archive_name}" | awk '{print $1}')" - elif command -v shasum > /dev/null 2>&1; then - actual_checksum="$(shasum -a 256 "${tmpdir}/${archive_name}" | awk '{print $1}')" - elif command -v openssl > /dev/null 2>&1; then - actual_checksum="$(openssl dgst -sha256 "${tmpdir}/${archive_name}" | awk '{print $NF}')" - else - error "No checksum tool found (need sha256sum, shasum, or openssl). Cannot verify download integrity." - fi + actual_checksum="$(calculate_sha256 "${tmpdir}/${archive_name}")" if [ "$actual_checksum" != "$expected_checksum" ]; then error "Checksum mismatch!\n Expected: $expected_checksum\n Actual: $actual_checksum" @@ -806,8 +918,12 @@ download_and_install() { # 展開用サブディレクトリを使い、アーカイブや checksum ファイルと混在させない。 local extract_dir="${tmpdir}/extract" mkdir -p "$extract_dir" + info "Checking archive member paths..." + validate_archive_members "${tmpdir}/${archive_name}" info "Extracting..." tar xzf "${tmpdir}/${archive_name}" -C "$extract_dir" + info "Verifying extracted payload..." + verify_payload_manifest "$extract_dir" # Validate the extracted payload before copying anything into INSTALL_DIR. # This avoids overwriting a healthy install with a partially broken one @@ -892,6 +1008,7 @@ download_and_install() { staged_assets="${staged_assets} ${asset}" fi done + write_integrity_version_json "${stage_dir}/version.json" chmod +x "${stage_dir}/${BINARY_NAME}" local backup_dir @@ -1054,23 +1171,21 @@ echo "mock ${BINARY_NAME} (${rehearsal_version}) for local mirror self-test" >&2 exit 2 EOF chmod +x "${local_payload_dir}/${BINARY_NAME}" - printf '{"version":"%s"}\n' "$rehearsal_version_no_prefix" > "${local_payload_dir}/version.json" + printf '{"version":"%s","integrity_ok":true}\n' "$rehearsal_version_no_prefix" > "${local_payload_dir}/version.json" : > "${local_payload_dir}/${runtime_asset}" ( cd "$local_payload_dir" - tar czf "../${archive_name}" "${BINARY_NAME}" version.json "${runtime_asset}" + { + calculate_sha256 "${BINARY_NAME}" | awk -v file="${BINARY_NAME}" '{ print $1 " " file }' + calculate_sha256 version.json | awk '{ print $1 " version.json" }' + calculate_sha256 "${runtime_asset}" | awk -v file="${runtime_asset}" '{ print $1 " " file }' + } > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp MANIFEST.sha256 + tar czf "../${archive_name}" MANIFEST.sha256 "${BINARY_NAME}" version.json "${runtime_asset}" ) - if command -v sha256sum > /dev/null 2>&1; then - checksum="$(sha256sum "${local_release_base}/${archive_name}" | awk '{print $1}')" - elif command -v shasum > /dev/null 2>&1; then - checksum="$(shasum -a 256 "${local_release_base}/${archive_name}" | awk '{print $1}')" - elif command -v openssl > /dev/null 2>&1; then - checksum="$(openssl dgst -sha256 "${local_release_base}/${archive_name}" | awk '{print $NF}')" - else - error "No checksum tool found (need sha256sum, shasum, or openssl) for local mirror self-test." - fi + checksum="$(calculate_sha256 "${local_release_base}/${archive_name}")" printf '%s %s\n' "$checksum" "$archive_name" > "${local_release_base}/sha256sums.txt" if has_explicit_self_test_install_dir; then diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 97ae0752bf..37e84fcfea 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -1,5 +1,8 @@ using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; using System.Runtime.Versioning; +using System.Text; namespace CodeIndex.Tests; @@ -63,7 +66,7 @@ shift 2 echo "cdidx v1.10.0" EOF chmod +x "{{Path.Combine(installDir, "cdidx")}}" - printf '{"version":"1.10.0"}' > "{{Path.Combine(installDir, "version.json")}}" + printf '{"version":"1.10.0","integrity_ok":true}' > "{{Path.Combine(installDir, "version.json")}}" : > "{{Path.Combine(installDir, nativeAssetName)}}" printf 'license text' > "{{Path.Combine(installDir, "LICENSE")}}" printf 'commercial license text' > "{{Path.Combine(installDir, "COMMERCIAL_LICENSE.md")}}" @@ -761,7 +764,7 @@ public void DownloadAndInstall_MissingAsset_DoesNotCreateFilesInEmptyInstallDir( EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -860,7 +863,7 @@ public void DownloadAndInstall_OptionalLicenseAssetsAreInstalledWhenPresent() printf 'trademark text' > "{{Path.Combine(payloadDir, "TRADEMARKS.md")}}" printf 'fsl text' > "{{Path.Combine(payloadDir, "LICENSES", "FSL-1.1-ALv2.txt")}}" printf 'apache text' > "{{Path.Combine(payloadDir, "LICENSES", "Apache-2.0.txt")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -935,6 +938,327 @@ shift 2 Assert.Equal(string.Empty, stderr); } + [Fact] + public void DownloadAndInstall_ReinstallReplacesExistingOptionalDirectories() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "replace_optional_target"); + var payloadDir = Path.Combine(_tempRoot, "replace_optional_payload"); + var archivePath = Path.Combine(_tempRoot, "replace_optional.tar.gz"); + var checksumsPath = Path.Combine(_tempRoot, "replace_optional.sha256sums.txt"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + mkdir -p "{{Path.Combine(installDir, "LICENSES")}}" + cat > "{{Path.Combine(installDir, "cdidx")}}" <<'EOF' + #!/usr/bin/env bash + echo "cdidx v1.24.6" + EOF + chmod +x "{{Path.Combine(installDir, "cdidx")}}" + printf '{"version":"1.24.6"}' > "{{Path.Combine(installDir, "version.json")}}" + printf 'old-lib' > "{{Path.Combine(installDir, "libe_sqlite3.so")}}" + printf 'stale' > "{{Path.Combine(installDir, "LICENSES", "stale.txt")}}" + + mkdir -p "{{payloadDir}}" + mkdir -p "{{Path.Combine(payloadDir, "LICENSES")}}" + cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' + #!/usr/bin/env bash + echo "cdidx v1.24.6" + EOF + chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" + printf '{"version":"1.24.6"}' > "{{Path.Combine(payloadDir, "version.json")}}" + printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" + printf 'new-license' > "{{Path.Combine(payloadDir, "LICENSES", "new.txt")}}" + make_payload_archive "{{payloadDir}}" "{{archivePath}}" + + checksum="$(calculate_sha256 "{{archivePath}}")" + printf '%s CodeIndex-linux-x64.tar.gz\n' "$checksum" > "{{checksumsPath}}" + + VERSION="v1.24.6" + OS_NAME="linux" + ARCH_NAME="x64" + RID="linux-x64" + + curl() { + local output_path="" + local url="" + while [ $# -gt 0 ]; do + case "$1" in + -o) output_path="$2"; shift 2 ;; + -w) shift 2 ;; + *) url="$1"; shift ;; + esac + done + case "$url" in + */sha256sums.txt) cp "{{checksumsPath}}" "$output_path" ;; + *) cp "{{archivePath}}" "$output_path" ;; + esac + printf '200' + return 0 + } + + download_and_install + echo "REINSTALL_OK" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }); + + Assert.Equal(0, exitCode); + Assert.Contains("REINSTALL_OK", stdout); + Assert.Equal(string.Empty, stderr); + Assert.Equal("new-license", File.ReadAllText(Path.Combine(installDir, "LICENSES", "new.txt"))); + Assert.False(File.Exists(Path.Combine(installDir, "LICENSES", "stale.txt"))); + Assert.False(Directory.Exists(Path.Combine(installDir, "LICENSES", "LICENSES"))); + } + + [Fact] + public void DownloadAndInstall_LegacyArchiveWithoutManifestStillInstalls() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "legacy_manifest_target"); + var payloadDir = Path.Combine(_tempRoot, "legacy_manifest_payload"); + var archivePath = Path.Combine(_tempRoot, "legacy_manifest.tar.gz"); + var checksumsPath = Path.Combine(_tempRoot, "legacy_manifest.sha256sums.txt"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + mkdir -p "{{payloadDir}}" + cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' + #!/usr/bin/env bash + echo "cdidx v1.24.5" + EOF + chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" + printf '{"version":"1.24.5"}' > "{{Path.Combine(payloadDir, "version.json")}}" + printf 'legacy-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" + tar czf "{{archivePath}}" -C "{{payloadDir}}" . + + checksum="$(calculate_sha256 "{{archivePath}}")" + printf '%s CodeIndex-linux-x64.tar.gz\n' "$checksum" > "{{checksumsPath}}" + + VERSION="v1.24.5" + OS_NAME="linux" + ARCH_NAME="x64" + RID="linux-x64" + + curl() { + local output_path="" + local url="" + while [ $# -gt 0 ]; do + case "$1" in + -o) output_path="$2"; shift 2 ;; + -w) shift 2 ;; + *) url="$1"; shift ;; + esac + done + case "$url" in + */sha256sums.txt) cp "{{checksumsPath}}" "$output_path" ;; + *) cp "{{archivePath}}" "$output_path" ;; + esac + printf '200' + return 0 + } + + download_and_install + echo "LEGACY_INSTALL_OK" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }); + + Assert.Equal(0, exitCode); + Assert.Contains("LEGACY_INSTALL_OK", stdout); + Assert.Contains("falling back to archive-level checksum verification", stderr); + Assert.Equal("""{"version":"1.24.5","integrity_ok":true}""" + Environment.NewLine, File.ReadAllText(Path.Combine(installDir, "version.json"))); + } + + [Fact] + public void DownloadAndInstall_NewArchiveWithoutManifestFails() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "new_manifest_required_target"); + var payloadDir = Path.Combine(_tempRoot, "new_manifest_required_payload"); + var archivePath = Path.Combine(_tempRoot, "new_manifest_required.tar.gz"); + var checksumsPath = Path.Combine(_tempRoot, "new_manifest_required.sha256sums.txt"); + + var (exitCode, _, stderr) = RunInstallerSnippet( + $$""" + mkdir -p "{{payloadDir}}" + cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' + #!/usr/bin/env bash + echo "cdidx v1.24.6" + EOF + chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" + printf '{"version":"1.24.6"}' > "{{Path.Combine(payloadDir, "version.json")}}" + printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" + tar czf "{{archivePath}}" -C "{{payloadDir}}" . + + checksum="$(calculate_sha256 "{{archivePath}}")" + printf '%s CodeIndex-linux-x64.tar.gz\n' "$checksum" > "{{checksumsPath}}" + + VERSION="v1.24.6" + OS_NAME="linux" + ARCH_NAME="x64" + RID="linux-x64" + + curl() { + local output_path="" + local url="" + while [ $# -gt 0 ]; do + case "$1" in + -o) output_path="$2"; shift 2 ;; + -w) shift 2 ;; + *) url="$1"; shift ;; + esac + done + case "$url" in + */sha256sums.txt) cp "{{checksumsPath}}" "$output_path" ;; + *) cp "{{archivePath}}" "$output_path" ;; + esac + printf '200' + return 0 + } + + download_and_install + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.Contains("Release payload is missing MANIFEST.sha256", stderr); + Assert.False(File.Exists(Path.Combine(installDir, "cdidx"))); + } + + [Fact] + public void ValidateArchiveMembers_RejectsTraversalBeforeExtraction() + { + if (OperatingSystem.IsWindows()) + return; + + var workDir = Path.Combine(_tempRoot, "unsafe_archive"); + var baseDir = Path.Combine(workDir, "base"); + var extractDir = Path.Combine(workDir, "extract"); + var archivePath = Path.Combine(workDir, "unsafe.tar.gz"); + var outsidePath = Path.Combine(workDir, "escape_marker"); + + Directory.CreateDirectory(workDir); + using (var archive = File.Create(archivePath)) + using (var gzip = new GZipStream(archive, CompressionLevel.SmallestSize)) + using (var writer = new TarWriter(gzip, leaveOpen: false)) + { + var bytes = Encoding.UTF8.GetBytes("escape"); + using var data = new MemoryStream(bytes); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "../escape_marker") + { + DataStream = data, + }); + } + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + mkdir -p "{{baseDir}}" "{{extractDir}}" + + status=0 + if validate_archive_members "{{archivePath}}"; then + tar xzf "{{archivePath}}" -C "{{extractDir}}" + else + status=$? + fi + + echo "STATUS:$status" + [ -e "{{outsidePath}}" ] && echo "OUTSIDE_CREATED" || echo "OUTSIDE_MISSING" + """, + enforceStrictMode: false); + + Assert.Equal(0, exitCode); + Assert.Contains("STATUS:1", stdout); + Assert.Contains("OUTSIDE_MISSING", stdout); + Assert.Contains("Release archive contains unsafe member path before extraction: ../escape_marker", stderr); + Assert.False(File.Exists(outsidePath)); + } + + [Fact] + public void DownloadAndInstall_ArchiveWithUnmanifestedFileFails() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "unmanifested_file_target"); + var payloadDir = Path.Combine(_tempRoot, "unmanifested_file_payload"); + var archivePath = Path.Combine(_tempRoot, "unmanifested_file.tar.gz"); + var checksumsPath = Path.Combine(_tempRoot, "unmanifested_file.sha256sums.txt"); + + var (exitCode, _, stderr) = RunInstallerSnippet( + $$""" + mkdir -p "{{payloadDir}}" + cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' + #!/usr/bin/env bash + echo "cdidx v1.24.6" + EOF + chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" + printf '{"version":"1.24.6"}' > "{{Path.Combine(payloadDir, "version.json")}}" + printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" + ( + cd "{{payloadDir}}" + find . -type f ! -name MANIFEST.sha256 ! -name .MANIFEST.sha256.tmp | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do + printf '%s %s\n' "$(calculate_sha256 "$file")" "$file" + done > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp MANIFEST.sha256 + ) + mkdir -p "{{Path.Combine(payloadDir, "LICENSES")}}" + printf 'extra-license' > "{{Path.Combine(payloadDir, "LICENSES", "extra.txt")}}" + tar czf "{{archivePath}}" -C "{{payloadDir}}" . + + checksum="$(calculate_sha256 "{{archivePath}}")" + printf '%s CodeIndex-linux-x64.tar.gz\n' "$checksum" > "{{checksumsPath}}" + + VERSION="v1.24.6" + OS_NAME="linux" + ARCH_NAME="x64" + RID="linux-x64" + + curl() { + local output_path="" + local url="" + while [ $# -gt 0 ]; do + case "$1" in + -o) output_path="$2"; shift 2 ;; + -w) shift 2 ;; + *) url="$1"; shift ;; + esac + done + case "$url" in + */sha256sums.txt) cp "{{checksumsPath}}" "$output_path" ;; + *) cp "{{archivePath}}" "$output_path" ;; + esac + printf '200' + return 0 + } + + download_and_install + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.Contains("Release payload contains file not listed in MANIFEST.sha256: LICENSES/extra.txt", stderr); + Assert.False(File.Exists(Path.Combine(installDir, "LICENSES", "extra.txt"))); + } + [Fact] public void DownloadAndInstall_StageDirMktempFailure_AbortsBeforeInstallWritesUnderStrictMode() { @@ -957,7 +1281,7 @@ public void DownloadAndInstall_StageDirMktempFailure_AbortsBeforeInstallWritesUn chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1064,7 +1388,7 @@ public void DownloadAndInstall_BackupDirMktempFailure_PreservesExistingHealthyIn chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1162,7 +1486,7 @@ public void DownloadAndInstall_MissingAsset_DoesNotOverwriteExistingHealthyInsta EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1267,7 +1591,7 @@ public void DownloadAndInstall_MoveFailure_RollsBackExistingHealthyInstall() chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1399,7 +1723,7 @@ public void DownloadAndInstall_BackupMoveFailure_PreservesExistingHealthyInstall chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1535,7 +1859,7 @@ public void DownloadAndInstall_RollbackFailure_PreservesRecoveryArtifacts() chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1669,7 +1993,7 @@ public void DownloadAndInstall_RestrictsStageAndBackupDirectories() chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -1809,7 +2133,7 @@ public void DownloadAndInstall_MissingChecksumEntry_PrintsActionableError() chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" : > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" printf '%s %s\n' deadbeef CodeIndex-linux-arm64.tar.gz > "{{checksumsPath}}" @@ -2842,7 +3166,7 @@ public void DownloadAndInstall_UsesConfiguredReleaseBaseUrl() chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" printf 'new-lib' > "{{Path.Combine(payloadDir, "libe_sqlite3.so")}}" - tar czf "{{archivePath}}" -C "{{payloadDir}}" . + make_payload_archive "{{payloadDir}}" "{{archivePath}}" if command -v sha256sum > /dev/null 2>&1; then checksum="$(sha256sum "{{archivePath}}" | awk '{print $1}')" @@ -4589,6 +4913,18 @@ private static (int ExitCode, string StdOut, string StdErr) RunInstallerSnippet( {{(enforceStrictMode ? "set -euo pipefail" : "")}} export CDIDX_INSTALL_SH_LIB_ONLY=1 source "{{GetInstallScriptPath()}}" + make_payload_archive() { + local payload_dir="$1" + local archive_path="$2" + ( + cd "$payload_dir" + find . -type f ! -name MANIFEST.sha256 ! -name .MANIFEST.sha256.tmp | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do + calculate_sha256 "$file" | awk -v file="$file" '{ print $1 " " file }' + done > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp MANIFEST.sha256 + ) + tar czf "$archive_path" -C "$payload_dir" . + } {{snippet}} """); File.SetUnixFileMode(scriptPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);