From bfc4e5d54771336ddd0adb66a0f0aff8a83fb2a0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:04:46 +0900 Subject: [PATCH 01/11] Stabilize release archive members (#2041) --- .codex/workflows/release-changelog.md | 6 +++++ .github/workflows/release.yml | 33 +++++++++++++++++++++++++-- changelog.d/unreleased/2041.fixed.md | 16 +++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2041.fixed.md diff --git a/.codex/workflows/release-changelog.md b/.codex/workflows/release-changelog.md index 0fdeed074b..fe17a5bb09 100644 --- a/.codex/workflows/release-changelog.md +++ b/.codex/workflows/release-changelog.md @@ -141,6 +141,12 @@ 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. + ```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 9a3004da0e..c9851bd60d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -277,14 +277,43 @@ jobs: run: | mkdir -p artifacts cd publish - tar czf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" . + find . -exec touch -t 200001010000 {} + + 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 | 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/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 を比較します。 From 9ffcff14a1491095c0c17a871243f1cb648e9290 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:07:39 +0900 Subject: [PATCH 02/11] Verify release payload manifest (#2040) --- .codex/workflows/release-changelog.md | 4 ++ .github/workflows/release.yml | 10 ++++ changelog.d/unreleased/2040.fixed.md | 17 +++++++ install.sh | 71 ++++++++++++++++++++------- 4 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 changelog.d/unreleased/2040.fixed.md diff --git a/.codex/workflows/release-changelog.md b/.codex/workflows/release-changelog.md index fe17a5bb09..8a9adc4cd1 100644 --- a/.codex/workflows/release-changelog.md +++ b/.codex/workflows/release-changelog.md @@ -147,6 +147,10 @@ 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, so do not remove or hand-edit it when diagnosing release artifacts. + ```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 c9851bd60d..ab19774e3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,6 +278,9 @@ jobs: mkdir -p artifacts cd publish find . -exec touch -t 200001010000 {} + + find . -type f | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do + sha256sum "$file" + done > 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" @@ -292,6 +295,13 @@ jobs: $fixedTimestamp = [DateTime]'2000-01-01T00:00:00Z' Get-ChildItem publish -Recurse | ForEach-Object { $_.LastWriteTimeUtc = $fixedTimestamp } $files = Get-ChildItem publish -File -Recurse | 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 + $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 { 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/install.sh b/install.sh index 3334b19cea..1e44191c32 100755 --- a/install.sh +++ b/install.sh @@ -510,6 +510,48 @@ 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 +} + +verify_payload_manifest() { + local extract_dir="$1" + local manifest="${extract_dir}/MANIFEST.sha256" + local line expected path actual + + if [ ! -f "$manifest" ]; then + error "Release payload is missing MANIFEST.sha256. Refusing to install without per-file integrity metadata." + 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 + if [ ! -f "${extract_dir}/${path}" ]; then + error "Release payload manifest entry missing after extraction: ${path}" + fi + actual="$(calculate_sha256 "${extract_dir}/${path}")" + if [ "$actual" != "$expected" ]; then + error "Release payload checksum mismatch for ${path}.\n Expected: ${expected}\n Actual: ${actual}" + fi + done < "$manifest" +} + restore_backed_up_files() { local backup_dir="$1" local install_dir="$2" @@ -787,15 +829,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" @@ -808,6 +842,8 @@ download_and_install() { mkdir -p "$extract_dir" 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 @@ -1059,18 +1095,15 @@ EOF ( 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 + 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 From e659e49bb653ac01f798559463d6098e0ddccbc2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:08:29 +0900 Subject: [PATCH 03/11] Require install integrity marker (#2012) --- changelog.d/unreleased/2012.fixed.md | 15 +++++++++++++++ install.sh | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2012.fixed.md 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/install.sh b/install.sh index 1e44191c32..f3b52585a8 100755 --- a/install.sh +++ b/install.sh @@ -490,6 +490,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 @@ -552,6 +555,13 @@ verify_payload_manifest() { done < "$manifest" } +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" @@ -928,6 +938,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 @@ -1090,7 +1101,7 @@ 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}" ( From 0b793f640f2a80e7807e320476da5596baa303c1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:27:31 +0900 Subject: [PATCH 04/11] Fix release manifest generation (#2040) --- .github/workflows/release.yml | 9 ++++-- install.sh | 3 +- tests/CodeIndex.Tests/InstallScriptTests.cs | 36 ++++++++++++++------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0136fa8e59..228f67b532 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,9 +278,10 @@ jobs: mkdir -p artifacts cd publish find . -exec touch -t 200001010000 {} + - find . -type f | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do + find . -type f ! -name MANIFEST.sha256 ! -name .MANIFEST.sha256.tmp | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do sha256sum "$file" - done > MANIFEST.sha256 + done > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp 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" @@ -294,7 +295,9 @@ jobs: New-Item -ItemType Directory -Force -Path artifacts $fixedTimestamp = [DateTime]'2000-01-01T00:00:00Z' Get-ChildItem publish -Recurse | ForEach-Object { $_.LastWriteTimeUtc = $fixedTimestamp } - $files = Get-ChildItem publish -File -Recurse | Sort-Object FullName + $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() diff --git a/install.sh b/install.sh index f3b52585a8..d054ed1155 100755 --- a/install.sh +++ b/install.sh @@ -1110,7 +1110,8 @@ EOF 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 + } > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp MANIFEST.sha256 tar czf "../${archive_name}" MANIFEST.sha256 "${BINARY_NAME}" version.json "${runtime_asset}" ) diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 97ae0752bf..1bc1a60fd3 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -63,7 +63,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 +761,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 +860,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}')" @@ -957,7 +957,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 +1064,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 +1162,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 +1267,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 +1399,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 +1535,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 +1669,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 +1809,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 +2842,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 +4589,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); From 3c8cd4640f86434347ffea8c664bbdac64fb878c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:45:34 +0900 Subject: [PATCH 05/11] Preserve legacy archive installs (#2040) --- .codex/workflows/release-changelog.md | 4 +- install.sh | 31 ++++- tests/CodeIndex.Tests/InstallScriptTests.cs | 125 ++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/.codex/workflows/release-changelog.md b/.codex/workflows/release-changelog.md index 8a9adc4cd1..15bca363a6 100644 --- a/.codex/workflows/release-changelog.md +++ b/.codex/workflows/release-changelog.md @@ -149,7 +149,9 @@ archive manually. Each release archive also contains `MANIFEST.sha256`, generated from the published payload before upload. `install.sh` verifies that manifest after -extraction, so do not remove or hand-edit it when diagnosing release artifacts. +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"' \ diff --git a/install.sh b/install.sh index d054ed1155..3f9d5ea55a 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="" @@ -533,7 +557,12 @@ verify_payload_manifest() { local line expected path actual if [ ! -f "$manifest" ]; then - error "Release payload is missing MANIFEST.sha256. Refusing to install without per-file integrity metadata." + 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 while IFS= read -r line || [ -n "$line" ]; do diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 1bc1a60fd3..560c8125ac 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -935,6 +935,131 @@ shift 2 Assert.Equal(string.Empty, stderr); } + [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 DownloadAndInstall_StageDirMktempFailure_AbortsBeforeInstallWritesUnderStrictMode() { From 711aa3af2d99872d079f565ce6783088507a14c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:47:28 +0900 Subject: [PATCH 06/11] Normalize release manifest timestamps (#2041) --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 228f67b532..b39e0c06a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -282,6 +282,7 @@ jobs: sha256sum "$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" @@ -304,6 +305,7 @@ jobs: "$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 From 61928d5451f8c51b415fed39b74be203f5903550 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:01:49 +0900 Subject: [PATCH 07/11] Use portable manifest hashing (#2041) --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b39e0c06a4..58730f3617 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,8 +278,13 @@ jobs: mkdir -p artifacts cd publish 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 - sha256sum "$file" + hash_file "$file" done > .MANIFEST.sha256.tmp mv .MANIFEST.sha256.tmp MANIFEST.sha256 touch -t 200001010000 MANIFEST.sha256 From 493979541b19c071748b4c54b6afe3f5ce880d70 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:13:31 +0900 Subject: [PATCH 08/11] Reject unmanifested payload files (#2040) --- install.sh | 28 +++++++- tests/CodeIndex.Tests/InstallScriptTests.cs | 71 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 3f9d5ea55a..aadb82eb20 100755 --- a/install.sh +++ b/install.sh @@ -554,7 +554,7 @@ calculate_sha256() { verify_payload_manifest() { local extract_dir="$1" local manifest="${extract_dir}/MANIFEST.sha256" - local line expected path actual + local manifest_paths line expected path actual extracted_paths if [ ! -f "$manifest" ]; then if semver_ge "${VERSION#v}" "$MANIFEST_REQUIRED_VERSION"; then @@ -565,6 +565,14 @@ verify_payload_manifest() { 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%% *}" @@ -574,14 +582,32 @@ verify_payload_manifest() { 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() { diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 560c8125ac..cf7c05cd10 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -1060,6 +1060,77 @@ tar czf "{{archivePath}}" -C "{{payloadDir}}" . Assert.False(File.Exists(Path.Combine(installDir, "cdidx"))); } + [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() { From 91bd853f39753c5f3df0c9569ca710ce5d95025d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:17:53 +0900 Subject: [PATCH 09/11] Replace optional assets during reinstall (#2012) --- install.sh | 2 +- tests/CodeIndex.Tests/InstallScriptTests.cs | 77 +++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index aadb82eb20..f76c89f154 100755 --- a/install.sh +++ b/install.sh @@ -662,7 +662,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." diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index cf7c05cd10..e7496a8edc 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -935,6 +935,83 @@ 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() { From 9409ad67dac2e86925e28ca2b809b154e03172cf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:23:40 +0900 Subject: [PATCH 10/11] Preflight release archive members (#2040) --- install.sh | 15 ++++++++ tests/CodeIndex.Tests/InstallScriptTests.cs | 38 +++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/install.sh b/install.sh index f76c89f154..cd30c8960d 100755 --- a/install.sh +++ b/install.sh @@ -551,6 +551,19 @@ calculate_sha256() { 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" @@ -905,6 +918,8 @@ 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..." diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index e7496a8edc..bb6eb218f4 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -1137,6 +1137,44 @@ tar czf "{{archivePath}}" -C "{{payloadDir}}" . 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"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + mkdir -p "{{baseDir}}" "{{extractDir}}" + printf 'escape' > "{{outsidePath}}" + tar czf "{{archivePath}}" -C "{{baseDir}}" ../escape_marker + rm "{{outsidePath}}" + + 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() { From ddb2412ded1f1b79ce4aea018f57bea6169905ff Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:32:41 +0900 Subject: [PATCH 11/11] Stabilize unsafe archive test (#2040) --- tests/CodeIndex.Tests/InstallScriptTests.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index bb6eb218f4..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; @@ -1149,12 +1152,22 @@ public void ValidateArchiveMembers_RejectsTraversalBeforeExtraction() 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}}" - printf 'escape' > "{{outsidePath}}" - tar czf "{{archivePath}}" -C "{{baseDir}}" ../escape_marker - rm "{{outsidePath}}" status=0 if validate_archive_members "{{archivePath}}"; then