diff --git a/install.ps1 b/install.ps1 index a434e78fb..dbdfedb6f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -14,13 +14,18 @@ Add-Type -AssemblyName System.Net.Http $Repo = "DeusData/codebase-memory-mcp" $InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp" $BinName = "codebase-memory-mcp.exe" -$WindowsArchiveNames = @( +# Core members shipped since the single-binary Windows layout. The integration +# manifest was added later (externalized runtime assets); published v0.9.0 zips +# still omit it, so validation accepts either the legacy 4-file core set or the +# current 5-file set. New releases from package-release.sh always ship both. +$WindowsCoreArchiveNames = @( $BinName, - "cbm-integrations.json", "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md" ) +$WindowsIntegrationArchiveName = "cbm-integrations.json" +$WindowsArchiveNames = $WindowsCoreArchiveNames + @($WindowsIntegrationArchiveName) $UiPackPattern = '^cbm-ui-[0-9a-f]{64}\.pack$' $BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" } @@ -232,8 +237,10 @@ try { # Validate the zip namespace before extraction. Windows paths are # case-insensitive, so two entries that differ only in case are ambiguous and -# must never be allowed to overwrite each other. The official five entries are -# required at the archive root; UI adds exactly one hash-shaped pack. +# must never be allowed to overwrite each other. Core members are always +# required; cbm-integrations.json is required when present and accepted as +# absent for legacy published archives (v0.9.0). UI adds exactly one +# hash-shaped pack on top of either layout. try { Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive") @@ -272,17 +279,22 @@ try { throw "archive contains an unexpected root entry: $($entry.FullName)" } } - foreach ($archiveName in $WindowsArchiveNames) { + foreach ($archiveName in $WindowsCoreArchiveNames) { if ($archiveCounts[$archiveName] -ne 1) { throw "archive must contain exactly one $archiveName" } } + $integrationCount = $archiveCounts[$WindowsIntegrationArchiveName] + if ($integrationCount -ne 0 -and $integrationCount -ne 1) { + throw "archive must contain exactly one $WindowsIntegrationArchiveName" + } $expectedUiPackCount = if ($Variant -eq "ui") { 1 } else { 0 } - $expectedArchiveCount = $WindowsArchiveNames.Count + $expectedUiPackCount + $expectedArchiveCount = $WindowsCoreArchiveNames.Count + $integrationCount + $expectedUiPackCount if ($uiPackCount -ne $expectedUiPackCount -or $seen.Count -ne $expectedArchiveCount) { throw "archive does not match the exact $Variant Windows release allowlist" } + $script:WindowsHasIntegrationManifest = ($integrationCount -eq 1) } finally { $zip.Dispose() } @@ -298,7 +310,11 @@ try { Write-Host "Extracting..." Expand-Archive -Path "$TmpDir\$Archive" -DestinationPath $TmpDir -Force -foreach ($archiveName in $WindowsArchiveNames) { +$RequiredExtractedMembers = @($WindowsCoreArchiveNames) +if ($WindowsHasIntegrationManifest) { + $RequiredExtractedMembers += $WindowsIntegrationArchiveName +} +foreach ($archiveName in $RequiredExtractedMembers) { $extractedMember = Join-Path $TmpDir $archiveName if (-not (Test-Path -LiteralPath $extractedMember -PathType Leaf)) { Write-Host "error: release member is not a regular file: $archiveName" -ForegroundColor Red diff --git a/install.sh b/install.sh index b9e9cea3f..8aed6591a 100755 --- a/install.sh +++ b/install.sh @@ -232,9 +232,10 @@ fi echo "Checksum verified." # Validate the complete archive namespace before extraction. Standard releases -# are the canonical five files; UI releases add exactly one root-level, -# content-addressed pack. Anything else is a release-integrity failure, not a -# sidecar to ignore. +# ship either the current five-file set (with cbm-integrations.json) or the +# legacy four-file set still published as v0.9.0. UI releases add exactly one +# root-level, content-addressed pack on top of either layout. Anything else is +# a release-integrity failure, not a sidecar to ignore. if [ "$OS" = "windows" ]; then ARCHIVE_BINARY="codebase-memory-mcp.exe" ARCHIVE_INSTALLER="install.ps1" @@ -284,13 +285,24 @@ while IFS= read -r member || [ -n "$member" ]; do esac done < "$ARCHIVE_MEMBERS_FILE" -EXPECTED_MEMBER_COUNT=5 +# Current releases: 5 members (+1 UI pack). Legacy published archives (v0.9.0) +# omit cbm-integrations.json and therefore have 4 core members. +if [ "$INTEGRATION_MEMBERS" -eq 1 ]; then + EXPECTED_CORE_COUNT=5 +elif [ "$INTEGRATION_MEMBERS" -eq 0 ]; then + EXPECTED_CORE_COUNT=4 +else + echo "error: release archive does not match the exact $VARIANT member set" >&2 + exit 1 +fi +EXPECTED_MEMBER_COUNT=$EXPECTED_CORE_COUNT if [ "$VARIANT" = "ui" ]; then - EXPECTED_MEMBER_COUNT=6 + EXPECTED_MEMBER_COUNT=$((EXPECTED_CORE_COUNT + 1)) fi -if [ "$BINARY_MEMBERS" -ne 1 ] || [ "$INTEGRATION_MEMBERS" -ne 1 ] || +if [ "$BINARY_MEMBERS" -ne 1 ] || [ "$LICENSE_MEMBERS" -ne 1 ] || [ "$INSTALLER_MEMBERS" -ne 1 ] || - [ "$NOTICE_MEMBERS" -ne 1 ] || [ "$UI_PACK_MEMBERS" -ne $((EXPECTED_MEMBER_COUNT - 5)) ] || + [ "$NOTICE_MEMBERS" -ne 1 ] || + [ "$UI_PACK_MEMBERS" -ne $((EXPECTED_MEMBER_COUNT - EXPECTED_CORE_COUNT)) ] || [ "$ARCHIVE_MEMBER_COUNT" -ne "$EXPECTED_MEMBER_COUNT" ]; then echo "error: release archive does not match the exact $VARIANT member set" >&2 exit 1 @@ -304,8 +316,16 @@ else tar -xzf "$DLDIR/$ARCHIVE" -C "$DLDIR" fi -for extracted_member in "$ARCHIVE_BINARY" cbm-integrations.json LICENSE \ - "$ARCHIVE_INSTALLER" THIRD_PARTY_NOTICES.md; do +REQUIRED_EXTRACTED_MEMBERS=( + "$ARCHIVE_BINARY" + LICENSE + "$ARCHIVE_INSTALLER" + THIRD_PARTY_NOTICES.md +) +if [ "$INTEGRATION_MEMBERS" -eq 1 ]; then + REQUIRED_EXTRACTED_MEMBERS+=(cbm-integrations.json) +fi +for extracted_member in "${REQUIRED_EXTRACTED_MEMBERS[@]}"; do if [ ! -f "$DLDIR/$extracted_member" ] || [ -L "$DLDIR/$extracted_member" ]; then echo "error: release member is not a regular file: $extracted_member" >&2 exit 1 diff --git a/tests/test_install_archive_layout_compat.sh b/tests/test_install_archive_layout_compat.sh new file mode 100755 index 000000000..fa0c7aa09 --- /dev/null +++ b/tests/test_install_archive_layout_compat.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Regression for #1499: published v0.9.0 Windows/Unix archives omit +# cbm-integrations.json, while main installers initially required it. Install +# scripts must accept both the legacy four-file core layout and the current +# five-file layout (core + cbm-integrations.json). package-release.sh still +# ships the five-file set for new builds. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$ROOT" <<'PY' +from __future__ import annotations + +import pathlib +import re +import sys +import tempfile +import zipfile + + +root = pathlib.Path(sys.argv[1]) +failures: list[str] = [] + + +def require(condition: bool, message: str) -> None: + if not condition: + failures.append(message) + + +install_ps1 = (root / "install.ps1").read_text(encoding="utf-8") +install_sh = (root / "install.sh").read_text(encoding="utf-8") +package_release = (root / "scripts/package-release.sh").read_text(encoding="utf-8") + +require( + "WindowsCoreArchiveNames" in install_ps1 + and "WindowsIntegrationArchiveName" in install_ps1 + and "WindowsHasIntegrationManifest" in install_ps1, + "install.ps1 must split core members from the optional integration manifest", +) +require( + "EXPECTED_CORE_COUNT=4" in install_sh and "EXPECTED_CORE_COUNT=5" in install_sh, + "install.sh must accept both the legacy 4-file and current 5-file layouts", +) +require( + "INTEGRATION_MEMBERS\" -eq 0" in install_sh + or '[ "$INTEGRATION_MEMBERS" -eq 0 ]' in install_sh, + "install.sh must treat a missing cbm-integrations.json as the legacy layout", +) +require( + "cbm-integrations.json LICENSE install.ps1" in package_release + and "cbm-integrations.json LICENSE install.sh" in package_release, + "package-release.sh must keep shipping cbm-integrations.json in new archives", +) + +# Extract the PowerShell validation core into a tiny harness that mirrors the +# allowlist logic without downloading anything. +core = [ + "codebase-memory-mcp.exe", + "LICENSE", + "install.ps1", + "THIRD_PARTY_NOTICES.md", +] +integration = "cbm-integrations.json" +ui_pack = "cbm-ui-" + ("a" * 64) + ".pack" + + +def validate(names: list[str], variant: str = "standard") -> str | None: + """Return None on success, else the error string.""" + archive_names = core + [integration] + counts = {name: 0 for name in archive_names} + seen: set[str] = set() + ui_pack_count = 0 + ui_pattern = re.compile(r"^cbm-ui-[0-9a-f]{64}\.pack$") + for entry_name in names: + if entry_name in seen: + return f"duplicate or case-conflicting zip entry: {entry_name}" + seen.add(entry_name) + if entry_name in counts: + counts[entry_name] += 1 + elif variant == "ui" and ui_pattern.match(entry_name): + ui_pack_count += 1 + else: + return f"archive contains an unexpected root entry: {entry_name}" + for name in core: + if counts[name] != 1: + return f"archive must contain exactly one {name}" + integration_count = counts[integration] + if integration_count not in (0, 1): + return f"archive must contain exactly one {integration}" + expected_ui = 1 if variant == "ui" else 0 + expected = len(core) + integration_count + expected_ui + if ui_pack_count != expected_ui or len(seen) != expected: + return f"archive does not match the exact {variant} Windows release allowlist" + return None + + +legacy = list(core) +current = list(core) + [integration] +legacy_ui = list(core) + [ui_pack] +current_ui = list(core) + [integration, ui_pack] + +require(validate(legacy) is None, f"legacy 4-file layout must pass: {validate(legacy)}") +require(validate(current) is None, f"current 5-file layout must pass: {validate(current)}") +require( + validate(legacy_ui, "ui") is None, + f"legacy UI layout must pass: {validate(legacy_ui, 'ui')}", +) +require( + validate(current_ui, "ui") is None, + f"current UI layout must pass: {validate(current_ui, 'ui')}", +) +require( + validate(core[:3]) is not None, + "incomplete core layout must still fail", +) +require( + validate(current + ["extra.txt"]) is not None, + "unexpected extra root entry must still fail", +) +require( + validate(core + [integration, integration]) is not None, + "duplicate integration manifest must still fail", +) + +# install.sh acceptance matrix for INTEGRATION_MEMBERS +sh_cases = [ + # binary, integration, license, installer, notice, ui, variant, expect_ok + (1, 1, 1, 1, 1, 0, "standard", True), + (1, 0, 1, 1, 1, 0, "standard", True), + (1, 1, 1, 1, 1, 1, "ui", True), + (1, 0, 1, 1, 1, 1, "ui", True), + (1, 2, 1, 1, 1, 0, "standard", False), + (1, 1, 1, 1, 0, 0, "standard", False), + (1, 0, 1, 1, 1, 0, "ui", False), # ui without pack +] + + +def sh_ok( + binary: int, + integration: int, + license_n: int, + installer: int, + notice: int, + ui: int, + variant: str, +) -> bool: + if integration == 1: + expected_core = 5 + elif integration == 0: + expected_core = 4 + else: + return False + expected_member = expected_core + (1 if variant == "ui" else 0) + archive_member = binary + integration + license_n + installer + notice + ui + return ( + binary == 1 + and license_n == 1 + and installer == 1 + and notice == 1 + and ui == (expected_member - expected_core) + and archive_member == expected_member + ) + + +for case in sh_cases: + *counts, variant, expect = case + got = sh_ok(*counts, variant) + require( + got is expect, + f"install.sh matrix {case} expected {expect}, got {got}", + ) + +# Prove a real zip with the legacy layout enumerates the way install.ps1 expects. +with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "legacy.zip" + with zipfile.ZipFile(path, "w") as zf: + for name in legacy: + zf.writestr(name, b"x") + names = zipfile.ZipFile(path).namelist() + require(validate(names) is None, f"zip legacy layout failed: {validate(names)}") + +if failures: + print("test_install_archive_layout_compat FAILED:") + for failure in failures: + print(f" - {failure}") + sys.exit(1) + +print("test_install_archive_layout_compat: ok") +PY diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index f793b299b..90f911656 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -195,8 +195,9 @@ require( "fixture checksums must name exact artifact basenames, never ./-prefixed paths", ) -# Native Windows packages and serves the exact five-file standard bundle or -# six-file UI bundle (one native binary), then runs the full smoke from a +# Native Windows packages and serves the current five-file standard bundle +# (or legacy four-file / matching UI +1 layouts) with one native binary, then +# runs the full smoke from a # protected profile-rooted directory/cache. for name in ( "codebase-memory-mcp.exe", @@ -380,13 +381,15 @@ require( needle in unix_installer for needle in ( "ARCHIVE_MEMBER_COUNT", - "EXPECTED_MEMBER_COUNT=5", - "EXPECTED_MEMBER_COUNT=6", + "EXPECTED_CORE_COUNT=4", + "EXPECTED_CORE_COUNT=5", + "EXPECTED_MEMBER_COUNT=$EXPECTED_CORE_COUNT", "^cbm-ui-[0-9a-f]{64}\\.pack$", "release archive contains unexpected member", ) ), - "install.sh must validate the exact five-member standard or six-member UI archive", + "install.sh must validate legacy 4-file and current 5-file standard archives " + "(plus optional UI pack on either layout)", ) unix_binary_publish = unix_installer.find('"$DLBIN" install "${INSTALL_ARGS[@]}"') require( diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 1661dfeda..7f89dce4c 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -279,7 +279,8 @@ for relative, patterns in single_binary_contracts.items(): # All package downloaders parse the Windows archive against an exact official # root allowlist; direct install.ps1 additionally distinguishes standard from -# UI by the single hash-shaped pack. +# UI by the single hash-shaped pack. install.ps1 also accepts the legacy +# four-file core set (no cbm-integrations.json) still published as v0.9.0. exact_archive_guards = { "install.ps1": ( "$seen.Count -ne $expectedArchiveCount", @@ -288,6 +289,8 @@ exact_archive_guards = { '"LICENSE"', '"install.ps1"', "THIRD_PARTY_NOTICES.md", + "WindowsCoreArchiveNames", + "WindowsIntegrationArchiveName", ), "pkg/npm/install.js": ( "seen.size !== expectedCount",