mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Break-glass merge PR #2664
This commit is contained in:
20 files changed
+1158
-944
No files matched your search
@@ -118,6 +118,7 @@ jobs:
|
||||
env:
|
||||
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
|
||||
SMOKE_REQUIRE_UI: "1"
|
||||
SMOKE_REQUIRE_WRAPPERS: "1"
|
||||
|
||||
- name: Security audits
|
||||
run: |
|
||||
@@ -224,6 +225,7 @@ jobs:
|
||||
env:
|
||||
SMOKE_ARCH: ${{ matrix.arch }}
|
||||
SMOKE_REQUIRE_UI: "1"
|
||||
SMOKE_REQUIRE_WRAPPERS: "1"
|
||||
run: |
|
||||
export CBM_SMOKE_ARTIFACT_DIR="$(cygpath -u "$RUNNER_TEMP")/cbm-artifact"
|
||||
bash test-infrastructure/vm/vm-smoke.sh
|
||||
@@ -301,6 +303,7 @@ jobs:
|
||||
env:
|
||||
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
|
||||
SMOKE_REQUIRE_UI: "1"
|
||||
SMOKE_REQUIRE_WRAPPERS: "1"
|
||||
|
||||
# The -portable binary is what all linux install/update paths now deliver;
|
||||
# it MUST start on old glibc (Debian 11 / RHEL 8 / Ubuntu 20.04). Runs it
|
||||
|
||||
@@ -232,6 +232,7 @@ jobs:
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 1000M
|
||||
SMOKE_REQUIRE_WRAPPERS: "1"
|
||||
|
||||
- name: Build prod + smoke (macOS)
|
||||
if: matrix.os == 'macos-14'
|
||||
@@ -242,6 +243,7 @@ jobs:
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 1000M
|
||||
SMOKE_REQUIRE_WRAPPERS: "1"
|
||||
|
||||
- name: Build prod + smoke (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -252,6 +254,7 @@ jobs:
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 1000M
|
||||
SMOKE_REQUIRE_WRAPPERS: "1"
|
||||
|
||||
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
|
||||
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
|
||||
|
||||
@@ -283,9 +283,15 @@ jobs:
|
||||
# move the release tag. (A floating HEAD also broke the 0.8.0 release:
|
||||
# HEAD had become a commit touching .github/workflows/, and the App
|
||||
# token may not create refs pointing at workflow-modifying commits.)
|
||||
# The Go wrapper is a nested module: `go install
|
||||
# .../pkg/go/cmd/codebase-memory-mcp@vX.Y.Z` and @latest resolve its
|
||||
# versions from pkg/go/v* tags only (without one, Go fetches the whole
|
||||
# repository, which is over its 500 MB module limit).
|
||||
run: |
|
||||
git tag -f "$VERSION" "$GITHUB_SHA"
|
||||
git push origin "$VERSION" --force
|
||||
git tag -f "pkg/go/$VERSION" "$GITHUB_SHA"
|
||||
git push origin "pkg/go/$VERSION" --force
|
||||
|
||||
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
@@ -510,6 +516,12 @@ jobs:
|
||||
else
|
||||
npm publish --access public --provenance
|
||||
fi
|
||||
# `next` is "the newest you can opt into": once a stable release
|
||||
# exists, an older RC must not stay behind it (0.9.1-rc.1 did until
|
||||
# v0.12.1). Re-runnable: dist-tag add is idempotent.
|
||||
if [[ "$RELEASE_VERSION" != *-* ]]; then
|
||||
npm dist-tag add "codebase-memory-mcp@$V" next
|
||||
fi
|
||||
|
||||
# ── PyPI ─────────────────────────────────────────────────
|
||||
- name: Setup Python
|
||||
|
||||
@@ -63,7 +63,7 @@ These captures show CBM Lens Beta on 12–13 October 2026, using public source f
|
||||
## Why codebase-memory-mcp
|
||||
|
||||
- **Indexing performance**: Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
|
||||
- **Plug and play**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
|
||||
- **Plug and play**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
|
||||
- **162 languages**: vendored tree-sitter grammars compiled into the binary. No separate parser installation.
|
||||
- **About 120× fewer tokens in the reported five-query comparison**: ~3,400 tokens vs ~412,000 via file-by-file search. See [Performance](#performance) for the scope and sources.
|
||||
- **45 supported automatic/conditional client surfaces**: `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
|
||||
@@ -360,7 +360,7 @@ The install script placed beside the binary is **reported, not deleted**: uninst
|
||||
- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
|
||||
- **Route nodes**: REST endpoints are first-class graph entities
|
||||
- **CLI mode**: `codebase-memory-mcp cli search_graph --project my-project --name-pattern ".*Handler.*"`
|
||||
- **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install`
|
||||
- **Available on**: npm, PyPI, Homebrew, Scoop, AUR, `go install`
|
||||
|
||||
### Indexing modes
|
||||
|
||||
|
||||
+2
-2
@@ -540,7 +540,7 @@
|
||||
Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI,
|
||||
Plandex, and SWE-agent. Warp is counted above for its detected skill installation; its MCP connection
|
||||
remains manual. Windows users run <code>install.ps1</code>. Also available via <code>npm</code>,
|
||||
<code>pip</code>, Homebrew, Scoop, Winget, Chocolatey, AUR, and <code>go install</code>.
|
||||
<code>pip</code>, Homebrew, Scoop, AUR, and <code>go install</code>.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Lifecycle installation follows documented context contracts: Qoder uses <code>SessionStart</code>,
|
||||
@@ -805,7 +805,7 @@
|
||||
|
||||
<h3>Do I need Docker or a runtime?</h3>
|
||||
<p>The native install needs no language runtime. It is a native executable with authenticated release-owned
|
||||
assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64); npm, PyPI, and Go wrapper
|
||||
assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64); npm, PyPI, and Go wrapper
|
||||
installations naturally use their package runtime.</p>
|
||||
|
||||
<h3>How does it stay up to date as I edit code?</h3>
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
- Supported agents: 45 automatic/conditional client surfaces (39 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, Oh My Pi (omp), and Sourcegraph Cody.
|
||||
- Agent profiles: documented custom-agent formats receive Scout (fast/provisional), Verify (default/task-directed), and Auditor (bounded/full verification) definitions. Every direct tier checks exact path/scope coverage with check_index_coverage and falls back to source for flagged gaps; unsafe child-MCP formats use explicit parent handoff. Kiro and Junie use positive-allowlist Scout/Analysis server profiles (7/11 tools); Qoder combines named-server selection with exact tier-specific MCP tool IDs, while Factory uses exact registered IDs without additive whole-server exposure. Foreign Junie aliases are preserved and force parent handoff.
|
||||
- Performance: Linux kernel (28M LOC, 75K files) full index in 3 minutes → 4.81M nodes, 7.72M edges; Cypher queries in under 1ms.
|
||||
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, and `go install`.
|
||||
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, AUR, and `go install`.
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
// Install:
|
||||
//
|
||||
// go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest
|
||||
//
|
||||
// For testing, CBM_DOWNLOAD_URL overrides the download base URL (default
|
||||
// https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>):
|
||||
// the archive and checksums.txt are fetched from <CBM_DOWNLOAD_URL>/<file>. It
|
||||
// must be https://, or plain http:// to exactly localhost, 127.0.0.1, or [::1]
|
||||
// (optional port; no proxy, no redirects).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,7 +33,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -39,16 +47,12 @@ const (
|
||||
version = "0.12.0"
|
||||
windowsBinaryName = "codebase-memory-mcp.exe"
|
||||
|
||||
maxRedirects = 5
|
||||
requestTimeout = 2 * time.Minute
|
||||
connectTimeout = 15 * time.Second
|
||||
responseHeaderTimeout = 30 * time.Second
|
||||
candidateTimeout = 15 * time.Second
|
||||
maxChecksumManifestSize = 1024 * 1024
|
||||
maxReleaseArchiveSize = int64(256 * 1024 * 1024)
|
||||
maxArchiveMembers = 64
|
||||
maxArchiveMemberSize = int64(256 * 1024 * 1024)
|
||||
maxArchiveExpandedSize = int64(512 * 1024 * 1024)
|
||||
maxRedirects = 5
|
||||
requestTimeout = 2 * time.Minute
|
||||
connectTimeout = 15 * time.Second
|
||||
responseHeaderTimeout = 30 * time.Second
|
||||
candidateTimeout = 15 * time.Second
|
||||
maxArchiveMembers = 64
|
||||
|
||||
runtimeSetLockName = ".codebase-memory-mcp-runtime.lock"
|
||||
runtimeSetLockWait = 45 * time.Second
|
||||
@@ -66,20 +70,6 @@ const (
|
||||
runtimeBackupCleanupRemovedEvent = "cleanup-member-removed"
|
||||
)
|
||||
|
||||
type archiveResourceLimits struct {
|
||||
compressedBytes int64
|
||||
members int
|
||||
memberBytes int64
|
||||
expandedBytes int64
|
||||
}
|
||||
|
||||
var defaultArchiveResourceLimits = archiveResourceLimits{
|
||||
compressedBytes: maxReleaseArchiveSize,
|
||||
members: maxArchiveMembers,
|
||||
memberBytes: maxArchiveMemberSize,
|
||||
expandedBytes: maxArchiveExpandedSize,
|
||||
}
|
||||
|
||||
type runtimeSetLock struct {
|
||||
path string
|
||||
token string
|
||||
@@ -169,7 +159,7 @@ func ensureBinary() (string, error) {
|
||||
|
||||
func binPath() string {
|
||||
return filepath.Join(
|
||||
cacheDir(), version, binaryNameForOS(runtime.GOOS),
|
||||
cacheDir(), installVersion, binaryNameForOS(runtime.GOOS),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -375,10 +365,14 @@ func download(dest string) error {
|
||||
"codebase-memory-mcp-%s-%s%s.%s",
|
||||
platform, arch, portable, ext,
|
||||
)
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, archive)
|
||||
checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/v%s/checksums.txt", repo, version)
|
||||
base, loopback, err := downloadBase(os.Getenv("CBM_DOWNLOAD_URL"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := base + "/" + archive
|
||||
checksumURL := base + "/checksums.txt"
|
||||
|
||||
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: downloading v%s for %s/%s...\n", version, platform, arch)
|
||||
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: downloading v%s for %s/%s...\n", installVersion, platform, arch)
|
||||
|
||||
tmp, err := os.MkdirTemp("", "cbm-install-*")
|
||||
if err != nil {
|
||||
@@ -387,13 +381,13 @@ func download(dest string) error {
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
archivePath := filepath.Join(tmp, "cbm."+ext)
|
||||
if err := httpGet(url, archivePath); err != nil {
|
||||
if err := httpGet(url, archivePath, loopback); err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// A release binary is executable input, so checksum verification is a
|
||||
// mandatory precondition rather than a best-effort warning.
|
||||
checksums, err := fetchChecksums(checksumURL)
|
||||
checksums, err := fetchChecksums(checksumURL, loopback)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum manifest unavailable: %w", err)
|
||||
}
|
||||
@@ -452,6 +446,64 @@ func download(dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loopbackHTTPURL is install.sh's pattern: plain HTTP only to an exact
|
||||
// loopback authority, for local smoke tests.
|
||||
// installVersion is the release this wrapper installs: its own module version
|
||||
// when Go installed it as a tagged release (`go install
|
||||
// .../pkg/go/cmd/codebase-memory-mcp@v0.12.1`, or @latest, which resolves to
|
||||
// the newest pkg/go/v* tag), else the version constant (a local build, or a
|
||||
// pseudo-version of an untagged commit).
|
||||
var installVersion = releaseVersionFrom(moduleVersion(), version)
|
||||
|
||||
var releaseTag = regexp.MustCompile(`^v([0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?)$`)
|
||||
|
||||
func moduleVersion() string {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
return info.Main.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// releaseVersionFrom maps a module version to a release version: "v0.12.1" is
|
||||
// "0.12.1"; "(devel)", a pseudo-version or "" fall back.
|
||||
func releaseVersionFrom(module, fallback string) string {
|
||||
if m := releaseTag.FindStringSubmatch(module); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
var loopbackHTTPURL = regexp.MustCompile(
|
||||
`^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?$`,
|
||||
)
|
||||
|
||||
var loopbackHostnames = map[string]bool{
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
"::1": true,
|
||||
}
|
||||
|
||||
// downloadBase returns the release download base and whether it is a
|
||||
// loopback HTTP base. CBM_DOWNLOAD_URL replaces the GitHub release base (for
|
||||
// testing) with install.sh's policy: HTTPS, or plain HTTP to an exact
|
||||
// loopback authority.
|
||||
func downloadBase(override string) (string, bool, error) {
|
||||
if override == "" {
|
||||
return fmt.Sprintf(
|
||||
"https://github.com/%s/releases/download/v%s", repo, installVersion,
|
||||
), false, nil
|
||||
}
|
||||
if strings.HasPrefix(override, "https://") {
|
||||
return override, false, nil
|
||||
}
|
||||
if loopbackHTTPURL.MatchString(override) {
|
||||
return override, true, nil
|
||||
}
|
||||
return "", false, fmt.Errorf(
|
||||
"refusing non-HTTPS download URL (CBM_DOWNLOAD_URL): %s", override,
|
||||
)
|
||||
}
|
||||
|
||||
// validateURLScheme rejects non-https URLs before any fetch (defense-in-depth).
|
||||
func validateURLScheme(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
@@ -461,6 +513,17 @@ func validateURLScheme(rawURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLoopbackURL accepts only plain HTTP to an exact loopback authority.
|
||||
func validateLoopbackURL(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || !loopbackHTTPURL.MatchString(rawURL) ||
|
||||
parsed.Scheme != "http" || parsed.User != nil ||
|
||||
!loopbackHostnames[parsed.Hostname()] {
|
||||
return fmt.Errorf("refusing non-loopback HTTP URL: %s", rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects.
|
||||
var httpsOnlyClient = &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
@@ -483,18 +546,40 @@ var httpsOnlyClient = &http.Client{
|
||||
},
|
||||
}
|
||||
|
||||
func httpGet(rawURL, dest string) error {
|
||||
return httpGetWithLimit(rawURL, dest, maxReleaseArchiveSize)
|
||||
// loopbackClient serves a loopback CBM_DOWNLOAD_URL: it uses no proxy and
|
||||
// refuses every redirect, so no hop can leave the loopback authority.
|
||||
var loopbackClient = &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return fmt.Errorf("refusing redirect from loopback download: %s", req.URL)
|
||||
},
|
||||
}
|
||||
|
||||
func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
|
||||
// downloadClient validates rawURL for its mode and returns the client for it.
|
||||
func downloadClient(rawURL string, loopback bool) (*http.Client, error) {
|
||||
if loopback {
|
||||
if err := validateLoopbackURL(rawURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loopbackClient, nil
|
||||
}
|
||||
if err := validateURLScheme(rawURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpsOnlyClient, nil
|
||||
}
|
||||
|
||||
func httpGet(rawURL, dest string, loopback bool) error {
|
||||
client, err := downloadClient(rawURL, loopback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return fmt.Errorf("invalid compressed archive safety limit")
|
||||
}
|
||||
resp, err := httpsOnlyClient.Get(rawURL) //nolint:gosec
|
||||
resp, err := client.Get(rawURL) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -502,24 +587,12 @@ func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, rawURL)
|
||||
}
|
||||
if resp.ContentLength > maxBytes {
|
||||
return fmt.Errorf(
|
||||
"release archive exceeds the %d-byte compressed safety limit",
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, copyErr := io.Copy(f, io.LimitReader(resp.Body, maxBytes+1))
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr == nil && written > maxBytes {
|
||||
copyErr = fmt.Errorf(
|
||||
"release archive exceeds the %d-byte compressed safety limit",
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(dest)
|
||||
return copyErr
|
||||
@@ -530,11 +603,12 @@ func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func fetchChecksums(url string) (map[string]string, error) {
|
||||
if err := validateURLScheme(url); err != nil {
|
||||
func fetchChecksums(url string, loopback bool) (map[string]string, error) {
|
||||
client, err := downloadClient(url, loopback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := httpsOnlyClient.Get(url) //nolint:gosec
|
||||
resp, err := client.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -542,13 +616,10 @@ func fetchChecksums(url string) (map[string]string, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumManifestSize+1))
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxChecksumManifestSize {
|
||||
return nil, fmt.Errorf("checksums.txt exceeds the 1 MiB safety limit")
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
parts := strings.Fields(line)
|
||||
@@ -634,17 +705,7 @@ func validateArchiveMemberNames(
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArchiveResourceLimits(limits archiveResourceLimits) error {
|
||||
if limits.compressedBytes <= 0 || limits.members <= 0 ||
|
||||
limits.memberBytes <= 0 || limits.expandedBytes <= 0 {
|
||||
return fmt.Errorf("invalid archive resource safety limits")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireCompressedArchiveWithinLimit(
|
||||
archivePath string, limits archiveResourceLimits,
|
||||
) error {
|
||||
func requireRegularArchive(archivePath string) error {
|
||||
status, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -652,91 +713,36 @@ func requireCompressedArchiveWithinLimit(
|
||||
if !status.Mode().IsRegular() {
|
||||
return fmt.Errorf("release archive is not a regular file")
|
||||
}
|
||||
if status.Size() > limits.compressedBytes {
|
||||
return fmt.Errorf(
|
||||
"release archive exceeds the %d-byte compressed safety limit",
|
||||
limits.compressedBytes,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func accountDeclaredArchiveMember(
|
||||
name string, size int64, expanded *int64, limits archiveResourceLimits,
|
||||
// copyArchiveMember copies one member and requires its declared size.
|
||||
func copyArchiveMember(
|
||||
destination io.Writer, source io.Reader, name string, declaredSize int64,
|
||||
) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("archive member has a negative declared size: %q", name)
|
||||
}
|
||||
if size > limits.memberBytes {
|
||||
return fmt.Errorf(
|
||||
"archive member %q exceeds the %d-byte expanded safety limit",
|
||||
name, limits.memberBytes,
|
||||
)
|
||||
}
|
||||
if *expanded > limits.expandedBytes-size {
|
||||
return fmt.Errorf(
|
||||
"archive exceeds the %d-byte aggregate expanded safety limit",
|
||||
limits.expandedBytes,
|
||||
)
|
||||
}
|
||||
*expanded += size
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyArchiveMemberWithLimits(
|
||||
destination io.Writer,
|
||||
source io.Reader,
|
||||
name string,
|
||||
declaredSize int64,
|
||||
actualExpanded *int64,
|
||||
limits archiveResourceLimits,
|
||||
) error {
|
||||
remaining := limits.expandedBytes - *actualExpanded
|
||||
copyLimit := limits.memberBytes
|
||||
if remaining < copyLimit {
|
||||
copyLimit = remaining
|
||||
}
|
||||
copied, err := io.Copy(
|
||||
destination, io.LimitReader(source, copyLimit+1),
|
||||
)
|
||||
copied, err := io.Copy(destination, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if copied > limits.memberBytes {
|
||||
return fmt.Errorf(
|
||||
"archive member %q exceeds the %d-byte actual expanded safety limit",
|
||||
name, limits.memberBytes,
|
||||
)
|
||||
}
|
||||
if copied > remaining {
|
||||
return fmt.Errorf(
|
||||
"archive exceeds the %d-byte aggregate actual expanded safety limit",
|
||||
limits.expandedBytes,
|
||||
)
|
||||
}
|
||||
if copied != declaredSize {
|
||||
return fmt.Errorf(
|
||||
"archive member %q actual size %d does not match declared size %d",
|
||||
name, copied, declaredSize,
|
||||
)
|
||||
}
|
||||
*actualExpanded += copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func tarGzMemberNames(archivePath string) ([]string, error) {
|
||||
return tarGzMemberNamesWithLimits(
|
||||
archivePath, defaultArchiveResourceLimits,
|
||||
)
|
||||
return tarGzMemberNamesWithMemberLimit(archivePath, maxArchiveMembers)
|
||||
}
|
||||
|
||||
func tarGzMemberNamesWithLimits(
|
||||
archivePath string, limits archiveResourceLimits,
|
||||
// tarGzMemberNamesWithMemberLimit lists the root members; the entry count
|
||||
// guard bounds the listing before the exact namespace check.
|
||||
func tarGzMemberNamesWithMemberLimit(
|
||||
archivePath string, maxMembers int,
|
||||
) ([]string, error) {
|
||||
if err := validateArchiveResourceLimits(limits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireCompressedArchiveWithinLimit(archivePath, limits); err != nil {
|
||||
if err := requireRegularArchive(archivePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(archivePath)
|
||||
@@ -751,7 +757,6 @@ func tarGzMemberNamesWithLimits(
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
var names []string
|
||||
var declaredExpanded int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
@@ -765,16 +770,11 @@ func tarGzMemberNamesWithLimits(
|
||||
"archive member is not a regular root file: %q", hdr.Name,
|
||||
)
|
||||
}
|
||||
if len(names) >= limits.members {
|
||||
if len(names) >= maxMembers {
|
||||
return nil, fmt.Errorf(
|
||||
"archive exceeds the %d-member safety limit", limits.members,
|
||||
"archive exceeds the %d-member safety limit", maxMembers,
|
||||
)
|
||||
}
|
||||
if err := accountDeclaredArchiveMember(
|
||||
hdr.Name, hdr.Size, &declaredExpanded, limits,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, hdr.Name)
|
||||
}
|
||||
return names, nil
|
||||
@@ -784,21 +784,21 @@ func extractTarGz(
|
||||
archivePath, destDir string,
|
||||
archiveNames, extractNames []string,
|
||||
) ([]string, error) {
|
||||
return extractTarGzWithLimits(
|
||||
return extractTarGzWithMemberLimit(
|
||||
archivePath,
|
||||
destDir,
|
||||
archiveNames,
|
||||
extractNames,
|
||||
defaultArchiveResourceLimits,
|
||||
maxArchiveMembers,
|
||||
)
|
||||
}
|
||||
|
||||
func extractTarGzWithLimits(
|
||||
func extractTarGzWithMemberLimit(
|
||||
archivePath, destDir string,
|
||||
archiveNames, extractNames []string,
|
||||
limits archiveResourceLimits,
|
||||
maxMembers int,
|
||||
) ([]string, error) {
|
||||
names, err := tarGzMemberNamesWithLimits(archivePath, limits)
|
||||
names, err := tarGzMemberNamesWithMemberLimit(archivePath, maxMembers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -824,8 +824,6 @@ func extractTarGzWithLimits(
|
||||
tr := tar.NewReader(gz)
|
||||
extracted := make(map[string]struct{}, len(runtimeNames))
|
||||
memberCount := 0
|
||||
var declaredExpanded int64
|
||||
var actualExpanded int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
@@ -835,9 +833,9 @@ func extractTarGzWithLimits(
|
||||
return nil, err
|
||||
}
|
||||
memberCount++
|
||||
if memberCount > limits.members {
|
||||
if memberCount > maxMembers {
|
||||
return nil, fmt.Errorf(
|
||||
"archive exceeds the %d-member safety limit", limits.members,
|
||||
"archive exceeds the %d-member safety limit", maxMembers,
|
||||
)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
@@ -845,11 +843,6 @@ func extractTarGzWithLimits(
|
||||
"archive member is not a regular root file: %q", hdr.Name,
|
||||
)
|
||||
}
|
||||
if err := accountDeclaredArchiveMember(
|
||||
hdr.Name, hdr.Size, &declaredExpanded, limits,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, wanted := targets[hdr.Name]; wanted {
|
||||
outputPath := filepath.Join(destDir, hdr.Name)
|
||||
out, err := os.OpenFile(
|
||||
@@ -860,9 +853,7 @@ func extractTarGzWithLimits(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copyErr := copyArchiveMemberWithLimits(
|
||||
out, tr, hdr.Name, hdr.Size, &actualExpanded, limits,
|
||||
)
|
||||
copyErr := copyArchiveMember(out, tr, hdr.Name, hdr.Size)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(outputPath)
|
||||
@@ -873,8 +864,8 @@ func extractTarGzWithLimits(
|
||||
return nil, closeErr
|
||||
}
|
||||
extracted[hdr.Name] = struct{}{}
|
||||
} else if err := copyArchiveMemberWithLimits(
|
||||
io.Discard, tr, hdr.Name, hdr.Size, &actualExpanded, limits,
|
||||
} else if err := copyArchiveMember(
|
||||
io.Discard, tr, hdr.Name, hdr.Size,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -889,24 +880,21 @@ func extractZip(
|
||||
archivePath, destDir string,
|
||||
archiveNames, extractNames []string,
|
||||
) ([]string, error) {
|
||||
return extractZipWithLimits(
|
||||
return extractZipWithMemberLimit(
|
||||
archivePath,
|
||||
destDir,
|
||||
archiveNames,
|
||||
extractNames,
|
||||
defaultArchiveResourceLimits,
|
||||
maxArchiveMembers,
|
||||
)
|
||||
}
|
||||
|
||||
func extractZipWithLimits(
|
||||
func extractZipWithMemberLimit(
|
||||
archivePath, destDir string,
|
||||
archiveNames, extractNames []string,
|
||||
limits archiveResourceLimits,
|
||||
maxMembers int,
|
||||
) ([]string, error) {
|
||||
if err := validateArchiveResourceLimits(limits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireCompressedArchiveWithinLimit(archivePath, limits); err != nil {
|
||||
if err := requireRegularArchive(archivePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
@@ -914,14 +902,13 @@ func extractZipWithLimits(
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
if len(r.File) > limits.members {
|
||||
if len(r.File) > maxMembers {
|
||||
return nil, fmt.Errorf(
|
||||
"archive exceeds the %d-member safety limit", limits.members,
|
||||
"archive exceeds the %d-member safety limit", maxMembers,
|
||||
)
|
||||
}
|
||||
names := make([]string, 0, len(r.File))
|
||||
declaredSizes := make(map[string]int64, len(r.File))
|
||||
var declaredExpanded int64
|
||||
for _, f := range r.File {
|
||||
name, err := validateWindowsZipMember(f.Name)
|
||||
if err != nil {
|
||||
@@ -932,17 +919,11 @@ func extractZipWithLimits(
|
||||
"archive member is not a regular root file: %q", f.Name,
|
||||
)
|
||||
}
|
||||
if f.UncompressedSize64 > uint64(limits.memberBytes) {
|
||||
return nil, fmt.Errorf(
|
||||
"archive member %q exceeds the %d-byte expanded safety limit",
|
||||
name, limits.memberBytes,
|
||||
)
|
||||
}
|
||||
declaredSize := int64(f.UncompressedSize64)
|
||||
if err := accountDeclaredArchiveMember(
|
||||
name, declaredSize, &declaredExpanded, limits,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
if declaredSize < 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"archive member has an invalid declared size: %q", name,
|
||||
)
|
||||
}
|
||||
names = append(names, name)
|
||||
declaredSizes[name] = declaredSize
|
||||
@@ -956,7 +937,6 @@ func extractZipWithLimits(
|
||||
targetNames[name] = struct{}{}
|
||||
}
|
||||
extracted := make(map[string]struct{}, len(runtimeNames))
|
||||
var actualExpanded int64
|
||||
|
||||
for index, target := range r.File {
|
||||
name := names[index]
|
||||
@@ -980,14 +960,7 @@ func extractZipWithLimits(
|
||||
}
|
||||
destination = out
|
||||
}
|
||||
copyErr := copyArchiveMemberWithLimits(
|
||||
destination,
|
||||
rc,
|
||||
name,
|
||||
declaredSizes[name],
|
||||
&actualExpanded,
|
||||
limits,
|
||||
)
|
||||
copyErr := copyArchiveMember(destination, rc, name, declaredSizes[name])
|
||||
var closeOutErr error
|
||||
if out != nil {
|
||||
closeOutErr = out.Close()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -29,18 +30,18 @@ func (transport archiveTestRoundTripper) RoundTrip(
|
||||
return transport(request)
|
||||
}
|
||||
|
||||
type archiveCountingBody struct {
|
||||
reader io.Reader
|
||||
read int64
|
||||
}
|
||||
// formerMemberCapBytes is the size cap the 0.12.0 wrappers enforced, which
|
||||
// refused every release.
|
||||
const formerMemberCapBytes = int64(256 * 1024 * 1024)
|
||||
|
||||
func (body *archiveCountingBody) Read(buffer []byte) (int, error) {
|
||||
count, err := body.reader.Read(buffer)
|
||||
body.read += int64(count)
|
||||
return count, err
|
||||
}
|
||||
type zeroReader struct{}
|
||||
|
||||
func (*archiveCountingBody) Close() error { return nil }
|
||||
func (zeroReader) Read(buffer []byte) (int, error) {
|
||||
for index := range buffer {
|
||||
buffer[index] = 0
|
||||
}
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
func assertRuntimeTag(t *testing.T, directory, binaryName, tag string) {
|
||||
t.Helper()
|
||||
@@ -157,164 +158,167 @@ func writeTarGz(t *testing.T, archivePath string, names []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressedArchiveDownloadRejectsDeclaredAndActualOverflow(t *testing.T) {
|
||||
const maxBytes = int64(8)
|
||||
func TestDownloadIgnoresADeclaredLengthAboveTheFormerCap(t *testing.T) {
|
||||
priorClient := httpsOnlyClient
|
||||
defer func() { httpsOnlyClient = priorClient }()
|
||||
|
||||
t.Run("declared", func(t *testing.T) {
|
||||
httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper(
|
||||
func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
ContentLength: maxBytes + 1,
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, nil
|
||||
},
|
||||
)}
|
||||
destination := filepath.Join(t.TempDir(), "release.tar.gz")
|
||||
err := httpGetWithLimit(
|
||||
"https://example.invalid/release.tar.gz", destination, maxBytes,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "compressed safety limit") {
|
||||
t.Fatalf("compressed declared overflow error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatal("declared oversized archive created a download")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("actual", func(t *testing.T) {
|
||||
body := &archiveCountingBody{reader: bytes.NewReader(
|
||||
bytes.Repeat([]byte("x"), 1024),
|
||||
)}
|
||||
httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper(
|
||||
func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: body,
|
||||
ContentLength: -1,
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, nil
|
||||
},
|
||||
)}
|
||||
destination := filepath.Join(t.TempDir(), "release.tar.gz")
|
||||
err := httpGetWithLimit(
|
||||
"https://example.invalid/release.tar.gz", destination, maxBytes,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "compressed safety limit") {
|
||||
t.Fatalf("compressed actual overflow error = %v", err)
|
||||
}
|
||||
if body.read != maxBytes+1 {
|
||||
t.Fatalf("oversized response bytes consumed = %d, want %d", body.read, maxBytes+1)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatal("rejected compressed archive left a partial download")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testArchiveLimits(
|
||||
members int, memberBytes, expandedBytes int64,
|
||||
) archiveResourceLimits {
|
||||
return archiveResourceLimits{
|
||||
compressedBytes: 1024 * 1024,
|
||||
members: members,
|
||||
memberBytes: memberBytes,
|
||||
expandedBytes: expandedBytes,
|
||||
httpsOnlyClient = &http.Client{Transport: archiveTestRoundTripper(
|
||||
func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("contents")),
|
||||
ContentLength: formerMemberCapBytes + 1,
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, nil
|
||||
},
|
||||
)}
|
||||
destination := filepath.Join(t.TempDir(), "release.tar.gz")
|
||||
if err := httpGet(
|
||||
"https://example.invalid/release.tar.gz", destination, false,
|
||||
); err != nil {
|
||||
t.Fatalf("download error = %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != "contents" {
|
||||
t.Fatalf("download stored %q", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTarArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) {
|
||||
func TestTarArchiveRejectsMoreMembersThanTheLimit(t *testing.T) {
|
||||
archivePath := filepath.Join(t.TempDir(), "members.tar.gz")
|
||||
writeTarGz(t, archivePath, []string{"one", "two", "three"})
|
||||
_, err := tarGzMemberNamesWithMemberLimit(archivePath, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "2-member safety limit") {
|
||||
t.Fatalf("tar member count error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCopyAcceptsAMemberLargerThanTheFormerCap(t *testing.T) {
|
||||
size := formerMemberCapBytes + 1024*1024
|
||||
if err := copyArchiveMember(
|
||||
io.Discard, io.LimitReader(zeroReader{}, size), "codebase-memory-mcp", size,
|
||||
); err != nil {
|
||||
t.Fatalf("copy of a %d-byte member error = %v", size, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCopyRequiresTheDeclaredSize(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
err := copyArchiveMember(&output, strings.NewReader("123"), "member", 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match declared size") {
|
||||
t.Fatalf("declared size mismatch error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBaseFollowsTheInstallShPolicy(t *testing.T) {
|
||||
githubBase := "https://github.com/DeusData/codebase-memory-mcp/releases/download/v" + version
|
||||
if base, loopback, err := downloadBase(""); err != nil || base != githubBase || loopback {
|
||||
t.Fatalf("unset override = (%q, %v, %v), want the GitHub base", base, loopback, err)
|
||||
}
|
||||
for _, override := range []string{
|
||||
"https://mirror.example/releases/v1",
|
||||
"https://127.0.0.1:8443/fixture",
|
||||
} {
|
||||
base, loopback, err := downloadBase(override)
|
||||
if err != nil || base != override || loopback {
|
||||
t.Errorf("https override %q = (%q, %v, %v)", override, base, loopback, err)
|
||||
}
|
||||
}
|
||||
for _, override := range []string{
|
||||
"http://localhost",
|
||||
"http://localhost:8080/fixture",
|
||||
"http://127.0.0.1:41234/standard",
|
||||
"http://[::1]:9/x?y#z",
|
||||
} {
|
||||
base, loopback, err := downloadBase(override)
|
||||
if err != nil || base != override || !loopback {
|
||||
t.Errorf("loopback override %q = (%q, %v, %v)", override, base, loopback, err)
|
||||
}
|
||||
}
|
||||
for _, override := range []string{
|
||||
"http://example.com/releases",
|
||||
"http://127.0.0.1.example.com/",
|
||||
"http://localhost.example.com:8080/",
|
||||
"http://user@localhost/",
|
||||
"http://127.0.0.2/",
|
||||
"HTTP://localhost/",
|
||||
"file:///tmp/releases",
|
||||
"ftp://localhost/releases",
|
||||
"github.com/DeusData/codebase-memory-mcp",
|
||||
} {
|
||||
_, _, err := downloadBase(override)
|
||||
if err == nil || !strings.Contains(
|
||||
err.Error(), "refusing non-HTTPS download URL (CBM_DOWNLOAD_URL)",
|
||||
) {
|
||||
t.Errorf("override %q error = %v, want a refusal", override, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRefusesURLsOutsideTheirMode(t *testing.T) {
|
||||
destination := filepath.Join(t.TempDir(), "release.tar.gz")
|
||||
err := httpGet("http://127.0.0.1/release.tar.gz", destination, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing non-https URL") {
|
||||
t.Fatalf("plain HTTP without a loopback base error = %v", err)
|
||||
}
|
||||
err = httpGet("http://example.invalid/release.tar.gz", destination, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing non-loopback HTTP URL") {
|
||||
t.Fatalf("non-loopback URL with a loopback base error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatal("refused download created a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackDownloadBypassesProxiesAndRefusesRedirects(t *testing.T) {
|
||||
transport, ok := loopbackClient.Transport.(*http.Transport)
|
||||
if !ok || transport.Proxy != nil {
|
||||
t.Fatal("the loopback client must not use a proxy")
|
||||
}
|
||||
digest := strings.Repeat("a", 64)
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(response http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/fixture/archive":
|
||||
_, _ = io.WriteString(response, "loopback contents")
|
||||
case "/fixture/checksums.txt":
|
||||
_, _ = io.WriteString(response, digest+" archive\n")
|
||||
default:
|
||||
http.Redirect(
|
||||
response, request, "https://example.invalid/elsewhere", http.StatusFound,
|
||||
)
|
||||
}
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
root := t.TempDir()
|
||||
tests := []struct {
|
||||
name string
|
||||
names []string
|
||||
limits archiveResourceLimits
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "member count",
|
||||
names: []string{"one", "two", "three"},
|
||||
limits: testArchiveLimits(2, 1024, 4096),
|
||||
wantError: "member safety limit",
|
||||
},
|
||||
{
|
||||
name: "declared member bytes",
|
||||
names: []string{"oversized"},
|
||||
limits: testArchiveLimits(4, 4, 4096),
|
||||
wantError: "expanded safety limit",
|
||||
},
|
||||
{
|
||||
name: "declared aggregate bytes",
|
||||
names: []string{"one", "two"},
|
||||
limits: testArchiveLimits(4, 1024, 15),
|
||||
wantError: "aggregate expanded safety limit",
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
archivePath := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+".tar.gz")
|
||||
writeTarGz(t, archivePath, testCase.names)
|
||||
_, err := tarGzMemberNamesWithLimits(archivePath, testCase.limits)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.wantError) {
|
||||
t.Fatalf("tar resource overflow error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCopyRejectsActualMemberAggregateAndMetadataMismatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contents string
|
||||
declared int64
|
||||
actual int64
|
||||
limits archiveResourceLimits
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "actual member bytes",
|
||||
contents: "12345",
|
||||
declared: 5,
|
||||
limits: testArchiveLimits(1, 4, 10),
|
||||
wantError: "actual expanded safety limit",
|
||||
},
|
||||
{
|
||||
name: "actual aggregate bytes",
|
||||
contents: "12",
|
||||
declared: 2,
|
||||
actual: 4,
|
||||
limits: testArchiveLimits(1, 10, 5),
|
||||
wantError: "aggregate actual expanded safety limit",
|
||||
},
|
||||
{
|
||||
name: "declared actual mismatch",
|
||||
contents: "123",
|
||||
declared: 2,
|
||||
limits: testArchiveLimits(1, 10, 10),
|
||||
wantError: "does not match declared size",
|
||||
},
|
||||
archivePath := filepath.Join(root, "archive")
|
||||
if err := httpGet(server.URL+"/fixture/archive", archivePath, true); err != nil {
|
||||
t.Fatalf("loopback download error = %v", err)
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
actual := testCase.actual
|
||||
var output bytes.Buffer
|
||||
err := copyArchiveMemberWithLimits(
|
||||
&output,
|
||||
strings.NewReader(testCase.contents),
|
||||
"member",
|
||||
testCase.declared,
|
||||
&actual,
|
||||
testCase.limits,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.wantError) {
|
||||
t.Fatalf("actual archive overflow error = %v", err)
|
||||
}
|
||||
})
|
||||
contents, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != "loopback contents" {
|
||||
t.Fatalf("loopback download stored %q", contents)
|
||||
}
|
||||
checksums, err := fetchChecksums(server.URL+"/fixture/checksums.txt", true)
|
||||
if err != nil || checksums["archive"] != digest {
|
||||
t.Fatalf("loopback checksums = %v, %v", checksums, err)
|
||||
}
|
||||
|
||||
redirected := filepath.Join(root, "redirected")
|
||||
err = httpGet(server.URL+"/fixture/elsewhere", redirected, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing redirect from loopback download") {
|
||||
t.Fatalf("loopback redirect error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(redirected); !os.IsNotExist(statErr) {
|
||||
t.Fatal("a refused loopback redirect created a file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,52 +346,20 @@ func writeZip(t *testing.T, archivePath string, names []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipArchiveRejectsMemberAndExpandedResourceOverflow(t *testing.T) {
|
||||
func TestZipArchiveRejectsMoreMembersThanTheLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tests := []struct {
|
||||
name string
|
||||
names []string
|
||||
limits archiveResourceLimits
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "member count",
|
||||
names: []string{"one", "two", "three"},
|
||||
limits: testArchiveLimits(2, 1024, 4096),
|
||||
wantError: "member safety limit",
|
||||
},
|
||||
{
|
||||
name: "declared member bytes",
|
||||
names: []string{"oversized"},
|
||||
limits: testArchiveLimits(4, 4, 4096),
|
||||
wantError: "expanded safety limit",
|
||||
},
|
||||
{
|
||||
name: "declared aggregate bytes",
|
||||
names: []string{"one", "two"},
|
||||
limits: testArchiveLimits(4, 1024, 15),
|
||||
wantError: "aggregate expanded safety limit",
|
||||
},
|
||||
names := []string{"one", "two", "three"}
|
||||
archivePath := filepath.Join(root, "members.zip")
|
||||
writeZip(t, archivePath, names)
|
||||
destination := filepath.Join(root, "extract")
|
||||
if err := os.Mkdir(destination, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
archivePath := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+".zip")
|
||||
writeZip(t, archivePath, testCase.names)
|
||||
destination := filepath.Join(root, strings.ReplaceAll(testCase.name, " ", "-")+"-extract")
|
||||
if err := os.Mkdir(destination, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := extractZipWithLimits(
|
||||
archivePath,
|
||||
destination,
|
||||
testCase.names,
|
||||
testCase.names[:1],
|
||||
testCase.limits,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.wantError) {
|
||||
t.Fatalf("zip resource overflow error = %v", err)
|
||||
}
|
||||
})
|
||||
_, err := extractZipWithMemberLimit(
|
||||
archivePath, destination, names, names[:1], 2,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "2-member safety limit") {
|
||||
t.Fatalf("zip member count error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,3 +1372,19 @@ func TestRuntimeSetLockSerializesProcesses(t *testing.T) {
|
||||
t.Fatalf("child never acquired the released runtime-set lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersionFromModuleVersion(t *testing.T) {
|
||||
cases := []struct{ module, want string }{
|
||||
{"v0.12.1", "0.12.1"},
|
||||
{"v0.9.1-rc.1", "0.9.1-rc.1"},
|
||||
{"(devel)", "fallback"},
|
||||
{"", "fallback"},
|
||||
{"v0.0.0-20261015165448-ec6427c38176", "fallback"},
|
||||
{"v0.12.2-0.20261016120000-0123456789ab", "fallback"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := releaseVersionFrom(c.module, "fallback"); got != c.want {
|
||||
t.Errorf("releaseVersionFrom(%q) = %q, want %q", c.module, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,30 @@
|
||||
class CodebaseMemoryMcp < Formula
|
||||
desc "Fast code intelligence engine for AI coding agents"
|
||||
homepage "https://github.com/DeusData/codebase-memory-mcp"
|
||||
version "0.10.3"
|
||||
version "0.12.0"
|
||||
license "MIT"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-darwin-arm64.tar.gz"
|
||||
sha256 "0ebf02328207d4c3d862c837b5e973de5bac808df92b0941737721d467287f7f"
|
||||
sha256 "1be41a9dd6902e3633eb0b280189117dc3db3be623bdd3dd4c6ea74372b5a50b"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-darwin-amd64.tar.gz"
|
||||
sha256 "1107fea28285823e1436e4f38a4e00a0b472d8a43c379da7dfd200c914a4b9dd"
|
||||
sha256 "af3beeeaca53204faf350402ddacee04780a53d0fa510dc31941fb5c2202ff35"
|
||||
end
|
||||
end
|
||||
|
||||
# The static -portable build: the dynamic one needs glibc 2.38, which
|
||||
# Ubuntu 22.04, Debian 12 and RHEL 9 do not have.
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-arm64.tar.gz"
|
||||
sha256 "967b9eababfdbd2ef1987c571d55bc7c028cd1db7f99279830634c58db311e32"
|
||||
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-arm64-portable.tar.gz"
|
||||
sha256 "8e603f32c9c84b5dc5a6b4a0d23516817d5de199e30f8dcc9bdb128c39386cd8"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-amd64.tar.gz"
|
||||
sha256 "74997fb0934e70a22f20c2e112fb4d883867dc1f01a7bcdc94cf86d13b5cbd31"
|
||||
url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-amd64-portable.tar.gz"
|
||||
sha256 "7024e585132760f383e3e25237e2b2ac6f5185f7e466f8dc66a085bb25f8d0be"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ npm install -g codebase-memory-mcp
|
||||
|
||||
The runtime set for your platform is downloaded automatically at install time. There is one composition per platform and the graph UI is always included — the former `CBM_VARIANT=ui` opt-in is obsolete.
|
||||
|
||||
For testing, `CBM_DOWNLOAD_URL` overrides the download base URL (default `https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>`): the archive and `checksums.txt` are fetched from `<CBM_DOWNLOAD_URL>/<file>`. It must be `https://`, or plain `http://` to exactly `localhost`, `127.0.0.1`, or `[::1]` (optional port; no proxy, no redirects).
|
||||
|
||||
Then configure your coding agents:
|
||||
|
||||
```bash
|
||||
|
||||
+78
-110
@@ -3,6 +3,7 @@
|
||||
// Postinstall script: downloads the platform-appropriate binary from GitHub Releases.
|
||||
// Runs automatically via `postinstall` in package.json.
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
@@ -17,19 +18,14 @@ const BIN_DIR = path.join(__dirname, 'bin');
|
||||
const MAX_REDIRECTS = 5;
|
||||
const DOWNLOAD_HOP_TIMEOUT_MS = 120_000;
|
||||
const CANDIDATE_TIMEOUT_MS = 15_000;
|
||||
const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024;
|
||||
// Release archive resource limits. Keep in sync with the Go wrapper
|
||||
// (pkg/go/cmd/codebase-memory-mcp/main.go) and the PyPI wrapper (_cli.py).
|
||||
const MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
||||
// Entry-count guard for the zip namespace check. Keep in sync with the Go
|
||||
// wrapper (pkg/go/cmd/codebase-memory-mcp/main.go) and the PyPI wrapper.
|
||||
const MAX_ARCHIVE_MEMBERS = 64;
|
||||
const MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024;
|
||||
const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
|
||||
compressedBytes: MAX_RELEASE_ARCHIVE_BYTES,
|
||||
members: MAX_ARCHIVE_MEMBERS,
|
||||
memberBytes: MAX_ARCHIVE_MEMBER_BYTES,
|
||||
expandedBytes: MAX_ARCHIVE_EXPANDED_BYTES,
|
||||
});
|
||||
const DEFAULT_DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/v${VERSION}`;
|
||||
// install.sh's loopback pattern: plain HTTP only to an exact loopback
|
||||
// authority, for local smoke tests.
|
||||
const LOOPBACK_HTTP_URL = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?$/;
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
// Bytes of tar's own diagnostics kept for the failure message.
|
||||
const MAX_TAR_STDERR_CHARS = 4096;
|
||||
const WINDOWS_BINARY_NAME = 'codebase-memory-mcp.exe';
|
||||
@@ -83,6 +79,32 @@ function validateUrl(rawUrl) {
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
// Plain HTTP is accepted only for an exact loopback authority, as in
|
||||
// install.sh; such a fetch uses no proxy and follows no redirect.
|
||||
function validateLoopbackUrl(rawUrl) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch (_) {
|
||||
// Refused below.
|
||||
}
|
||||
if (!LOOPBACK_HTTP_URL.test(rawUrl) || !parsed || parsed.protocol !== 'http:' ||
|
||||
!LOOPBACK_HOSTNAMES.has(parsed.hostname) || parsed.username || parsed.password) {
|
||||
throw new Error(`Refusing non-loopback HTTP URL: ${rawUrl}`);
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
// CBM_DOWNLOAD_URL replaces the release download base (for testing), with
|
||||
// install.sh's policy: HTTPS, or plain HTTP to an exact loopback authority.
|
||||
function resolveDownloadBase(env = process.env) {
|
||||
const override = env.CBM_DOWNLOAD_URL;
|
||||
if (!override) return { base: DEFAULT_DOWNLOAD_BASE, loopback: false };
|
||||
if (override.startsWith('https://')) return { base: override, loopback: false };
|
||||
if (LOOPBACK_HTTP_URL.test(override)) return { base: override, loopback: true };
|
||||
throw new Error(`Refusing non-HTTPS download URL (CBM_DOWNLOAD_URL): ${override}`);
|
||||
}
|
||||
|
||||
function validateExactTarMemberListing(listing, expectedNames) {
|
||||
if (listing.includes('\0')) {
|
||||
throw new Error('tar member listing contains a NUL byte');
|
||||
@@ -104,31 +126,16 @@ function validateExactTarMemberListing(listing, expectedNames) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateArchiveLimits(limits) {
|
||||
if (!limits || !(limits.compressedBytes > 0) || !(limits.members > 0) ||
|
||||
!(limits.memberBytes > 0) || !(limits.expandedBytes > 0)) {
|
||||
throw new Error('invalid archive resource safety limits');
|
||||
}
|
||||
}
|
||||
|
||||
function requireCompressedArchiveWithinLimit(archivePath, limits) {
|
||||
const status = fs.statSync(archivePath);
|
||||
if (!status.isFile()) {
|
||||
function requireRegularArchive(archivePath) {
|
||||
if (!fs.statSync(archivePath).isFile()) {
|
||||
throw new Error('release archive is not a regular file');
|
||||
}
|
||||
if (status.size > limits.compressedBytes) {
|
||||
throw new Error(
|
||||
`release archive exceeds the ${limits.compressedBytes}-byte compressed safety limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Streams one member through `tar -xzOf` into a file this call creates,
|
||||
// counting every byte against the per-member limit. The file is removed only
|
||||
// when this call created it: a pre-existing target fails untouched.
|
||||
function extractTarMemberWithLimit(
|
||||
archivePath, target, memberName, limits, spawnFile = spawn,
|
||||
) {
|
||||
// Streams one member through `tar -xzOf` into a file this call creates. The
|
||||
// file is removed only when this call created it: a pre-existing target
|
||||
// fails untouched.
|
||||
function extractTarMember(archivePath, target, memberName, spawnFile = spawn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let fd;
|
||||
try {
|
||||
@@ -164,12 +171,6 @@ function extractTarMemberWithLimit(
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (failure) return;
|
||||
written += chunk.length;
|
||||
if (written > limits.memberBytes) {
|
||||
fail(new Error(
|
||||
`archive member ${JSON.stringify(memberName)} exceeds the ${limits.memberBytes}-byte expanded safety limit`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
@@ -209,29 +210,24 @@ function extractTarMemberWithLimit(
|
||||
|
||||
async function extractExactTarArchive(
|
||||
archivePath, destPath, expectedNames, targetName,
|
||||
runFile = execFileSync, limits = DEFAULT_ARCHIVE_LIMITS, spawnFile = spawn,
|
||||
runFile = execFileSync, spawnFile = spawn,
|
||||
) {
|
||||
validateArchiveLimits(limits);
|
||||
requireCompressedArchiveWithinLimit(archivePath, limits);
|
||||
// The exact root namespace is the member bound: its four names lie well
|
||||
// within MAX_ARCHIVE_MEMBERS, which the zip path enforces by count.
|
||||
if (expectedNames.length > limits.members) {
|
||||
throw new Error(`archive namespace exceeds the ${limits.members}-member safety limit`);
|
||||
}
|
||||
requireRegularArchive(archivePath);
|
||||
// The exact root namespace is the member bound: the listing must name
|
||||
// exactly the expected root files.
|
||||
const listing = runFile('tar', ['-tzf', archivePath], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_CHECKSUM_MANIFEST_BYTES,
|
||||
windowsHide: true,
|
||||
});
|
||||
validateExactTarMemberListing(listing, expectedNames);
|
||||
// Only the fixed root executable is ever written, through the counted
|
||||
// writer; the companions are listed but never extracted.
|
||||
// Only the fixed root executable is ever written; the companions are
|
||||
// listed but never extracted.
|
||||
const target = path.join(destPath, targetName);
|
||||
await extractTarMemberWithLimit(archivePath, target, targetName, limits, spawnFile);
|
||||
await extractTarMember(archivePath, target, targetName, spawnFile);
|
||||
fs.chmodSync(target, 0o755);
|
||||
}
|
||||
|
||||
function downloadHop(url, dest, maxBytes) {
|
||||
function downloadHop(url, dest, loopback) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let response = null;
|
||||
@@ -245,7 +241,11 @@ function downloadHop(url, dest, maxBytes) {
|
||||
else resolve(result);
|
||||
}
|
||||
|
||||
const req = https.get(url, (res) => {
|
||||
// A loopback hop gets a fresh agent, so no proxy configuration applies.
|
||||
const get = loopback
|
||||
? (target, callback) => http.get(target, { agent: false }, callback)
|
||||
: (target, callback) => https.get(target, callback);
|
||||
const req = get(url, (res) => {
|
||||
response = res;
|
||||
let piped = false;
|
||||
// Before the body is piped, a response error ends the hop directly;
|
||||
@@ -255,6 +255,11 @@ function downloadHop(url, dest, maxBytes) {
|
||||
const redirectCodes = new Set([301, 302, 303, 307, 308]);
|
||||
if (redirectCodes.has(res.statusCode)) {
|
||||
const location = res.headers.location;
|
||||
if (loopback) {
|
||||
res.destroy();
|
||||
finish(new Error(`Refusing redirect from loopback download ${url}`));
|
||||
return;
|
||||
}
|
||||
if (!location) {
|
||||
res.destroy();
|
||||
finish(new Error(`Redirect with no location for ${url}`));
|
||||
@@ -277,20 +282,7 @@ function downloadHop(url, dest, maxBytes) {
|
||||
finish(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
return;
|
||||
}
|
||||
const declared = Number(res.headers['content-length']);
|
||||
if (maxBytes && Number.isFinite(declared) && declared > maxBytes) {
|
||||
res.destroy();
|
||||
finish(new Error(`Download exceeds the ${maxBytes}-byte safety limit`));
|
||||
return;
|
||||
}
|
||||
|
||||
let received = 0;
|
||||
res.on('data', (chunk) => {
|
||||
received += chunk.length;
|
||||
if (maxBytes && received > maxBytes) {
|
||||
res.destroy(new Error(`Download exceeds the ${maxBytes}-byte safety limit`));
|
||||
}
|
||||
});
|
||||
const file = fs.createWriteStream(dest, { flags: 'w' });
|
||||
piped = true;
|
||||
pipeline(res, file, (err) => {
|
||||
@@ -317,10 +309,10 @@ function downloadHop(url, dest, maxBytes) {
|
||||
});
|
||||
}
|
||||
|
||||
async function download(rawUrl, dest, maxBytes = 0) {
|
||||
let url = validateUrl(rawUrl);
|
||||
async function download(rawUrl, dest, loopback = false) {
|
||||
let url = loopback ? validateLoopbackUrl(rawUrl) : validateUrl(rawUrl);
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
const result = await downloadHop(url, dest, maxBytes);
|
||||
const result = await downloadHop(url, dest, loopback);
|
||||
if (!result.redirect) return;
|
||||
if (redirects === MAX_REDIRECTS) throw new Error('Too many redirects');
|
||||
url = result.redirect;
|
||||
@@ -605,10 +597,9 @@ function installWindowsBinaryAtomically(sourceDir, destDir, verifier = verifyCan
|
||||
|
||||
function extractZipOnWindows(
|
||||
archivePath, destPath, requiredNames, extractNames,
|
||||
limits = DEFAULT_ARCHIVE_LIMITS, shell = 'powershell',
|
||||
maxMembers = MAX_ARCHIVE_MEMBERS, shell = 'powershell',
|
||||
) {
|
||||
validateArchiveLimits(limits);
|
||||
requireCompressedArchiveWithinLimit(archivePath, limits);
|
||||
requireRegularArchive(archivePath);
|
||||
// -EncodedCommand is a constant program. Paths travel only through the child
|
||||
// environment and are consumed with -LiteralPath, so PowerShell never parses
|
||||
// user/TEMP path bytes as source code or wildcard syntax.
|
||||
@@ -616,10 +607,7 @@ function extractZipOnWindows(
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.IO.Compression.FileSystem',
|
||||
'$maxMembers = [int]$env:CBM_NPM_MAX_MEMBERS',
|
||||
'$maxMemberBytes = [long]$env:CBM_NPM_MAX_MEMBER_BYTES',
|
||||
'$maxExpandedBytes = [long]$env:CBM_NPM_MAX_EXPANDED_BYTES',
|
||||
'$memberCount = 0',
|
||||
'$declaredExpanded = [long]0',
|
||||
'$zip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)',
|
||||
"$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)",
|
||||
"$requiredNames = @($env:CBM_NPM_REQUIRED_NAMES.Split('|'))",
|
||||
@@ -629,11 +617,6 @@ function extractZipOnWindows(
|
||||
'$memberCount = $memberCount + 1; ' +
|
||||
'if ($memberCount -gt $maxMembers) { ' +
|
||||
'throw "archive exceeds the $maxMembers-member safety limit" }; ' +
|
||||
'if ($entry.Length -gt $maxMemberBytes) { ' +
|
||||
'throw "archive member $($entry.FullName) exceeds the $maxMemberBytes-byte expanded safety limit" }; ' +
|
||||
'if ($declaredExpanded -gt ($maxExpandedBytes - $entry.Length)) { ' +
|
||||
'throw "archive exceeds the $maxExpandedBytes-byte aggregate expanded safety limit" }; ' +
|
||||
'$declaredExpanded = $declaredExpanded + $entry.Length; ' +
|
||||
"$name = $entry.FullName.Replace('\\', '/'); " +
|
||||
"$directory = $name.EndsWith('/'); " +
|
||||
"$segmentsPath = if ($directory) { $name.TrimEnd('/') } else { $name }; " +
|
||||
@@ -655,15 +638,13 @@ function extractZipOnWindows(
|
||||
'if ($seen.Count -ne $requiredNames.Count) { ' +
|
||||
'throw "archive does not match the exact release root-file allowlist" }',
|
||||
"$extractNames = @($env:CBM_NPM_EXTRACT_NAMES.Split('|'))",
|
||||
'$actualExpanded = [long]0',
|
||||
'$buffer = New-Object byte[] 65536',
|
||||
'$extractZip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)',
|
||||
// Copy by hand with a byte counter: ExtractToFile would write whatever the
|
||||
// compressed stream yields, regardless of the declared size.
|
||||
// Copy by hand so the bytes written can be compared with the declared
|
||||
// size; ExtractToFile would accept whatever the compressed stream yields.
|
||||
'try { foreach ($extractName in $extractNames) { ' +
|
||||
'$entry = @($extractZip.Entries | Where-Object { $_.FullName -ceq $extractName })[0]; ' +
|
||||
'$targetPath = Join-Path $env:CBM_NPM_DEST_PATH $extractName; ' +
|
||||
'$remaining = $maxExpandedBytes - $actualExpanded; ' +
|
||||
'$copied = [long]0; ' +
|
||||
'$source = $entry.Open(); ' +
|
||||
'$target = [System.IO.File]::Open($targetPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write); ' +
|
||||
@@ -671,18 +652,13 @@ function extractZipOnWindows(
|
||||
'$count = $source.Read($buffer, 0, $buffer.Length); ' +
|
||||
'if ($count -le 0) { break }; ' +
|
||||
'$copied = $copied + $count; ' +
|
||||
'if ($copied -gt $maxMemberBytes) { ' +
|
||||
'throw "archive member $extractName exceeds the $maxMemberBytes-byte actual expanded safety limit" }; ' +
|
||||
'if ($copied -gt $remaining) { ' +
|
||||
'throw "archive exceeds the $maxExpandedBytes-byte aggregate actual expanded safety limit" }; ' +
|
||||
'$target.Write($buffer, 0, $count) } ' +
|
||||
'} catch { $target.Dispose(); ' +
|
||||
'Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue; throw ' +
|
||||
'} finally { $target.Dispose(); $source.Dispose() }; ' +
|
||||
'if ($copied -ne $entry.Length) { ' +
|
||||
'Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue; ' +
|
||||
'throw "archive member $extractName actual size $copied does not match declared size $($entry.Length)" }; ' +
|
||||
'$actualExpanded = $actualExpanded + $copied ' +
|
||||
'throw "archive member $extractName actual size $copied does not match declared size $($entry.Length)" } ' +
|
||||
'} } finally { $extractZip.Dispose() }',
|
||||
].join('; ');
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||
@@ -696,39 +672,30 @@ function extractZipOnWindows(
|
||||
CBM_NPM_DEST_PATH: destPath,
|
||||
CBM_NPM_REQUIRED_NAMES: requiredNames.join('|'),
|
||||
CBM_NPM_EXTRACT_NAMES: extractNames.join('|'),
|
||||
CBM_NPM_MAX_MEMBERS: String(limits.members),
|
||||
CBM_NPM_MAX_MEMBER_BYTES: String(limits.memberBytes),
|
||||
CBM_NPM_MAX_EXPANDED_BYTES: String(limits.expandedBytes),
|
||||
CBM_NPM_MAX_MEMBERS: String(maxMembers),
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'inherit', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
// Surface the script's own reason (for example which limit was hit)
|
||||
// Surface the script's own reason (for example which check failed)
|
||||
// instead of a bare "Command failed".
|
||||
const detail = err && err.stderr ? String(err.stderr).trim() : '';
|
||||
throw new Error(detail ? `zip extraction failed: ${detail}` : err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch checksums.txt and verify the archive hash.
|
||||
async function verifyChecksum(archivePath, archiveName) {
|
||||
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt`;
|
||||
// Fetch checksums.txt from the same download base and verify the archive hash.
|
||||
async function verifyChecksum(archivePath, archiveName, source) {
|
||||
const url = `${source.base}/checksums.txt`;
|
||||
const tmpChecksums = archivePath + '.checksums';
|
||||
try {
|
||||
await download(url, tmpChecksums, MAX_CHECKSUM_MANIFEST_BYTES);
|
||||
const manifestSize = fs.statSync(tmpChecksums).size;
|
||||
if (manifestSize > MAX_CHECKSUM_MANIFEST_BYTES) {
|
||||
throw new Error('checksums.txt exceeds the 1 MiB safety limit');
|
||||
}
|
||||
await download(url, tmpChecksums, source.loopback);
|
||||
const expected = parseExpectedChecksum(
|
||||
fs.readFileSync(tmpChecksums, 'utf-8'), archiveName,
|
||||
);
|
||||
const actual = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(archivePath))
|
||||
.digest('hex');
|
||||
const actual = fileSha256(archivePath);
|
||||
if (expected !== actual) {
|
||||
throw new Error(
|
||||
`Checksum mismatch for ${archiveName}:\n expected: ${expected}\n actual: ${actual}`,
|
||||
@@ -772,6 +739,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const source = resolveDownloadBase();
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
|
||||
// Linux ships a fully-static "-portable" build; the standard linux binary
|
||||
@@ -782,7 +750,7 @@ async function main() {
|
||||
// always embedded. The old CBM_VARIANT=ui opt-in pointed at ui-* archives
|
||||
// that no longer exist, so honoring it could only 404 (#1538).
|
||||
const archive = `codebase-memory-mcp-${platform}-${arch}${variant}.${ext}`;
|
||||
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archive}`;
|
||||
const url = `${source.base}/${archive}`;
|
||||
|
||||
process.stdout.write(`codebase-memory-mcp: downloading v${VERSION} for ${platform}/${arch}...\n`);
|
||||
|
||||
@@ -790,8 +758,8 @@ async function main() {
|
||||
const tmpArchive = path.join(tmpDir, `cbm.${ext}`);
|
||||
|
||||
try {
|
||||
await download(url, tmpArchive, MAX_RELEASE_ARCHIVE_BYTES);
|
||||
await verifyChecksum(tmpArchive, archive);
|
||||
await download(url, tmpArchive, source.loopback);
|
||||
await verifyChecksum(tmpArchive, archive, source);
|
||||
|
||||
// Validate the complete archive namespace, then extract only executables.
|
||||
if (ext === 'tar.gz') {
|
||||
@@ -872,7 +840,6 @@ if (require.main === module || module.parent == null) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_ARCHIVE_LIMITS,
|
||||
UNIX_ARCHIVE_NAMES,
|
||||
WINDOWS_ARCHIVE_NAMES,
|
||||
WINDOWS_BINARY_NAME,
|
||||
@@ -880,6 +847,7 @@ module.exports = {
|
||||
extractExactTarArchive,
|
||||
extractZipOnWindows,
|
||||
installWindowsBinaryAtomically,
|
||||
resolveDownloadBase,
|
||||
validateExactTarMemberListing,
|
||||
windowsBinaryReady,
|
||||
};
|
||||
+152
-125
@@ -3,6 +3,7 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { Readable } = require('node:stream');
|
||||
@@ -10,20 +11,23 @@ const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const {
|
||||
DEFAULT_ARCHIVE_LIMITS,
|
||||
UNIX_ARCHIVE_NAMES,
|
||||
WINDOWS_ARCHIVE_NAMES,
|
||||
WINDOWS_BINARY_NAME,
|
||||
download,
|
||||
extractExactTarArchive,
|
||||
extractZipOnWindows,
|
||||
resolveDownloadBase,
|
||||
} = require('../install.js');
|
||||
const {
|
||||
TAR_PATH_SKIP, buildZip, patternBytes, writeTarGz,
|
||||
} = require('./archive-fixtures.js');
|
||||
|
||||
const EXECUTABLE = 'codebase-memory-mcp';
|
||||
// Spans several pipe chunks, so an over-limit member is cut off mid-stream.
|
||||
// Spans several pipe chunks, so the counted writer sees a multi-chunk stream.
|
||||
const LARGE_EXECUTABLE = patternBytes(300 * 1024);
|
||||
// The size cap the 0.12.0 wrappers enforced, which refused every release.
|
||||
const FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
function withScratch(callback) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-archive-test-'));
|
||||
@@ -32,10 +36,6 @@ function withScratch(callback) {
|
||||
.finally(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
}
|
||||
|
||||
function limitsWith(overrides) {
|
||||
return { ...DEFAULT_ARCHIVE_LIMITS, ...overrides };
|
||||
}
|
||||
|
||||
function releaseEntries(names, executableData) {
|
||||
return names.map((name) => ({
|
||||
name,
|
||||
@@ -61,59 +61,6 @@ function fakeTarListing(names) {
|
||||
};
|
||||
}
|
||||
|
||||
function neverLists() {
|
||||
throw new Error('the listing must not run');
|
||||
}
|
||||
|
||||
test('tar extraction refuses an archive larger than the compressed limit', { skip: TAR_PATH_SKIP }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
|
||||
const destination = makeDestination(root);
|
||||
|
||||
await assert.rejects(
|
||||
extractExactTarArchive(
|
||||
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
|
||||
neverLists, limitsWith({ compressedBytes: 16 }),
|
||||
),
|
||||
/16-byte compressed safety limit/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(destination), []);
|
||||
}));
|
||||
|
||||
test('tar extraction refuses an exact namespace wider than the member limit', { skip: TAR_PATH_SKIP }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
|
||||
const destination = makeDestination(root);
|
||||
|
||||
await assert.rejects(
|
||||
extractExactTarArchive(
|
||||
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
|
||||
neverLists, limitsWith({ members: 2 }),
|
||||
),
|
||||
/2-member safety limit/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(destination), []);
|
||||
}));
|
||||
|
||||
test('tar extraction stops an over-limit member in the counted writer and leaves no file', { skip: TAR_PATH_SKIP }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = writeTarGz(
|
||||
root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE),
|
||||
);
|
||||
const destination = makeDestination(root);
|
||||
|
||||
// The real listing passes (names only); the limit trips while tar is
|
||||
// still streaming the executable into the writer.
|
||||
await assert.rejects(
|
||||
extractExactTarArchive(
|
||||
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
|
||||
undefined, limitsWith({ memberBytes: 64 * 1024 }),
|
||||
),
|
||||
/"codebase-memory-mcp" exceeds the 65536-byte expanded safety limit/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(destination), []);
|
||||
}));
|
||||
|
||||
test('tar extraction leaves a pre-existing target untouched', { skip: TAR_PATH_SKIP }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
|
||||
@@ -181,7 +128,7 @@ test('tar extraction installs the executable byte-identical with the executable
|
||||
assert.deepEqual(fs.readdirSync(destination), [EXECUTABLE]);
|
||||
}));
|
||||
|
||||
function loadInstallerWithHttps(fakeHttps) {
|
||||
function loadInstallerWithTransports({ fakeHttps, fakeHttp }) {
|
||||
const installPath = path.join(__dirname, '..', 'install.js');
|
||||
const source = fs.readFileSync(installPath, 'utf8');
|
||||
const sandbox = {
|
||||
@@ -197,7 +144,8 @@ function loadInstallerWithHttps(fakeHttps) {
|
||||
process,
|
||||
require: (specifier) => {
|
||||
if (specifier === './package.json') return { version: '0.0.0-test' };
|
||||
if (specifier === 'https') return fakeHttps;
|
||||
if (specifier === 'https' && fakeHttps) return fakeHttps;
|
||||
if (specifier === 'http' && fakeHttp) return fakeHttp;
|
||||
return require(specifier);
|
||||
},
|
||||
setTimeout,
|
||||
@@ -206,10 +154,13 @@ function loadInstallerWithHttps(fakeHttps) {
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
// Answers every request with one HTTP 200 response shaped by `configure`.
|
||||
function fakeHttpsServing(configure) {
|
||||
// Answers every request with one HTTP 200 response shaped by `configure`;
|
||||
// `requests` records each (url, options) the installer issued.
|
||||
function fakeTransportServing(configure, requests = []) {
|
||||
return {
|
||||
get(url, callback) {
|
||||
get(url, ...rest) {
|
||||
const callback = rest[rest.length - 1];
|
||||
requests.push({ url, options: rest.length > 1 ? rest[0] : undefined });
|
||||
const request = new EventEmitter();
|
||||
request.destroy = (err) => { if (err) request.emit('error', err); };
|
||||
setImmediate(() => {
|
||||
@@ -224,50 +175,146 @@ function fakeHttpsServing(configure) {
|
||||
};
|
||||
}
|
||||
|
||||
test('download refuses a response whose declared length exceeds the limit', () =>
|
||||
function refusingTransport() {
|
||||
return {
|
||||
get(url) {
|
||||
throw new Error(`unexpected request to ${url}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('download stores a body whose declared length exceeds the former 256 MiB cap', () =>
|
||||
withScratch(async (root) => {
|
||||
const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => {
|
||||
response.headers['content-length'] = '9';
|
||||
response.push(null);
|
||||
}));
|
||||
const { download: fakeDownload } = loadInstallerWithTransports({
|
||||
fakeHttps: fakeTransportServing((response) => {
|
||||
response.headers['content-length'] = String(FORMER_MEMBER_CAP_BYTES + 1);
|
||||
response.push('payload');
|
||||
response.push(null);
|
||||
}),
|
||||
});
|
||||
const destination = path.join(root, 'release.tar.gz');
|
||||
|
||||
await assert.rejects(
|
||||
download('https://example.invalid/release.tar.gz', destination, 8),
|
||||
/8-byte safety limit/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
}));
|
||||
|
||||
test('download refuses a body that exceeds the limit and removes the partial file', () =>
|
||||
withScratch(async (root) => {
|
||||
const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => {
|
||||
response.push(Buffer.alloc(1024, 0x78));
|
||||
response.push(null);
|
||||
}));
|
||||
const destination = path.join(root, 'release.tar.gz');
|
||||
|
||||
await assert.rejects(
|
||||
download('https://example.invalid/release.tar.gz', destination, 8),
|
||||
/8-byte safety limit/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
}));
|
||||
|
||||
test('download within the limit stores the body', () =>
|
||||
withScratch(async (root) => {
|
||||
const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => {
|
||||
response.headers['content-length'] = '7';
|
||||
response.push('payload');
|
||||
response.push(null);
|
||||
}));
|
||||
const destination = path.join(root, 'release.tar.gz');
|
||||
|
||||
await download('https://example.invalid/release.tar.gz', destination, 1024);
|
||||
await fakeDownload('https://example.invalid/release.tar.gz', destination);
|
||||
|
||||
assert.equal(fs.readFileSync(destination, 'utf8'), 'payload');
|
||||
}));
|
||||
|
||||
test('download refuses plain HTTP unless the base is a loopback override', () =>
|
||||
withScratch(async (root) => {
|
||||
const { download: fakeDownload } = loadInstallerWithTransports({
|
||||
fakeHttps: refusingTransport(),
|
||||
fakeHttp: refusingTransport(),
|
||||
});
|
||||
const destination = path.join(root, 'release.tar.gz');
|
||||
|
||||
await assert.rejects(
|
||||
fakeDownload('http://127.0.0.1/release.tar.gz', destination),
|
||||
/Refusing non-HTTPS or credentialed URL/,
|
||||
);
|
||||
await assert.rejects(
|
||||
fakeDownload('http://example.invalid/release.tar.gz', destination, true),
|
||||
/Refusing non-loopback HTTP URL/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
}));
|
||||
|
||||
test('a loopback download uses a fresh agent, so no proxy applies', () =>
|
||||
withScratch(async (root) => {
|
||||
const requests = [];
|
||||
const { download: fakeDownload } = loadInstallerWithTransports({
|
||||
fakeHttps: refusingTransport(),
|
||||
fakeHttp: fakeTransportServing((response) => {
|
||||
response.push('payload');
|
||||
response.push(null);
|
||||
}, requests),
|
||||
});
|
||||
const destination = path.join(root, 'release.tar.gz');
|
||||
|
||||
await fakeDownload('http://[::1]:8080/release.tar.gz', destination, true);
|
||||
|
||||
assert.equal(fs.readFileSync(destination, 'utf8'), 'payload');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].url, 'http://[::1]:8080/release.tar.gz');
|
||||
assert.equal(requests[0].options.agent, false);
|
||||
}));
|
||||
|
||||
function withLoopbackServer(handler, callback) {
|
||||
const server = http.createServer(handler);
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
}).then(() => Promise.resolve()
|
||||
.then(() => callback(`http://127.0.0.1:${server.address().port}`))
|
||||
.finally(() => new Promise((resolve) => server.close(resolve))));
|
||||
}
|
||||
|
||||
test('a loopback download fetches from a local server and refuses its redirects', () =>
|
||||
withScratch((root) => withLoopbackServer((request, response) => {
|
||||
if (request.url === '/release/codebase-memory-mcp.tar.gz') {
|
||||
response.writeHead(200, { 'content-type': 'application/octet-stream' });
|
||||
response.end('loopback payload');
|
||||
return;
|
||||
}
|
||||
response.writeHead(302, { location: 'https://example.invalid/elsewhere' });
|
||||
response.end();
|
||||
}, async (origin) => {
|
||||
const archive = path.join(root, 'release.tar.gz');
|
||||
await download(`${origin}/release/codebase-memory-mcp.tar.gz`, archive, true);
|
||||
assert.equal(fs.readFileSync(archive, 'utf8'), 'loopback payload');
|
||||
|
||||
const redirected = path.join(root, 'redirected.tar.gz');
|
||||
await assert.rejects(
|
||||
download(`${origin}/release/checksums.txt`, redirected, true),
|
||||
/Refusing redirect from loopback download/,
|
||||
);
|
||||
assert.equal(fs.existsSync(redirected), false);
|
||||
})));
|
||||
|
||||
test('CBM_DOWNLOAD_URL follows install.sh: HTTPS or exact-loopback HTTP only', () => {
|
||||
const { version } = require('../package.json');
|
||||
const githubBase = `https://github.com/DeusData/codebase-memory-mcp/releases/download/v${version}`;
|
||||
assert.deepEqual(resolveDownloadBase({}), { base: githubBase, loopback: false });
|
||||
assert.deepEqual(
|
||||
resolveDownloadBase({ CBM_DOWNLOAD_URL: '' }), { base: githubBase, loopback: false },
|
||||
);
|
||||
|
||||
for (const base of [
|
||||
'https://mirror.example/releases/v1',
|
||||
'https://127.0.0.1:8443/fixture',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
resolveDownloadBase({ CBM_DOWNLOAD_URL: base }), { base, loopback: false }, base,
|
||||
);
|
||||
}
|
||||
for (const base of [
|
||||
'http://localhost',
|
||||
'http://localhost:8080/fixture',
|
||||
'http://127.0.0.1:41234/standard',
|
||||
'http://[::1]:9/x?y#z',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
resolveDownloadBase({ CBM_DOWNLOAD_URL: base }), { base, loopback: true }, base,
|
||||
);
|
||||
}
|
||||
for (const base of [
|
||||
'http://example.com/releases',
|
||||
'http://127.0.0.1.example.com/',
|
||||
'http://localhost.example.com:8080/',
|
||||
'http://user@localhost/',
|
||||
'http://127.0.0.2/',
|
||||
'HTTP://localhost/',
|
||||
'file:///tmp/releases',
|
||||
'ftp://localhost/releases',
|
||||
'github.com/DeusData/codebase-memory-mcp',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => resolveDownloadBase({ CBM_DOWNLOAD_URL: base }),
|
||||
/Refusing non-HTTPS download URL \(CBM_DOWNLOAD_URL\)/,
|
||||
base,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The Windows path runs a constant PowerShell program through powershell.exe,
|
||||
// which only a Windows host provides (pwsh is not a wrapper dependency, and
|
||||
// no PowerShell is installed on the Unix wrapper hosts), so these cases run
|
||||
@@ -288,33 +335,14 @@ test('Windows zip extraction refuses an archive with more members than the limit
|
||||
|
||||
assert.throws(
|
||||
() => extractZipOnWindows(
|
||||
archive, destination, ['one', 'two', 'three'], ['one'],
|
||||
limitsWith({ members: 2 }),
|
||||
archive, destination, ['one', 'two', 'three'], ['one'], 2,
|
||||
),
|
||||
/2-member safety limit/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(destination), []);
|
||||
}));
|
||||
|
||||
test('Windows zip extraction refuses a member whose declared size exceeds the limit', { skip: WINDOWS_ONLY }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = path.join(root, 'member.zip');
|
||||
fs.writeFileSync(archive, buildZip([
|
||||
{ name: WINDOWS_BINARY_NAME, data: Buffer.alloc(65, 0x78) },
|
||||
]));
|
||||
const destination = makeDestination(root);
|
||||
|
||||
assert.throws(
|
||||
() => extractZipOnWindows(
|
||||
archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME],
|
||||
limitsWith({ memberBytes: 64 }),
|
||||
),
|
||||
/64-byte expanded safety limit/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(destination), []);
|
||||
}));
|
||||
|
||||
test('Windows zip extraction stops a member that yields more bytes than it declares', { skip: WINDOWS_ONLY }, () =>
|
||||
test('Windows zip extraction rejects a member that yields more bytes than it declares', { skip: WINDOWS_ONLY }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = path.join(root, 'longer.zip');
|
||||
fs.writeFileSync(archive, buildZip([
|
||||
@@ -325,14 +353,13 @@ test('Windows zip extraction stops a member that yields more bytes than it decla
|
||||
assert.throws(
|
||||
() => extractZipOnWindows(
|
||||
archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME],
|
||||
limitsWith({ memberBytes: 64 }),
|
||||
),
|
||||
/64-byte actual expanded safety limit/,
|
||||
/actual size \d+ does not match declared size 10/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(destination), []);
|
||||
}));
|
||||
|
||||
test('Windows zip extraction within the limits installs only the executable', { skip: WINDOWS_ONLY }, () =>
|
||||
test('Windows zip extraction installs only the executable', { skip: WINDOWS_ONLY }, () =>
|
||||
withScratch(async (root) => {
|
||||
const archive = path.join(root, 'release.zip');
|
||||
fs.writeFileSync(archive, buildZip(releaseEntries(WINDOWS_ARCHIVE_NAMES)));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
@@ -51,3 +52,58 @@ test('postinstall exits nonzero when installation fails', async () => {
|
||||
'npm postinstall reported success after a deterministic installation failure',
|
||||
);
|
||||
});
|
||||
|
||||
test('postinstall refuses a non-loopback http CBM_DOWNLOAD_URL before any download', async () => {
|
||||
const installPath = path.join(__dirname, '..', 'install.js');
|
||||
const source = fs.readFileSync(installPath, 'utf8');
|
||||
// A fresh package directory: no cached binary, and nothing may be created.
|
||||
const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-override-test-'));
|
||||
const output = { stdout: '', stderr: '' };
|
||||
const observed = { exitCode: undefined };
|
||||
const fakeProcess = {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
env: { CBM_DOWNLOAD_URL: 'http://example.com/releases' },
|
||||
pid: 12345,
|
||||
stdout: { write: (text) => { output.stdout += String(text); } },
|
||||
stderr: { write: (text) => { output.stderr += String(text); } },
|
||||
exit: (code) => { observed.exitCode = code; },
|
||||
exitCode: undefined,
|
||||
};
|
||||
const refusingTransport = {
|
||||
get(url) { throw new Error(`unexpected request to ${url}`); },
|
||||
};
|
||||
|
||||
try {
|
||||
const sandbox = {
|
||||
Buffer,
|
||||
URL,
|
||||
__dirname: packageDir,
|
||||
__filename: path.join(packageDir, 'install.js'),
|
||||
clearTimeout,
|
||||
console,
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
process: fakeProcess,
|
||||
require: (specifier) => {
|
||||
if (specifier === './package.json') return { version: '0.0.0-test' };
|
||||
if (specifier === 'https' || specifier === 'http') return refusingTransport;
|
||||
return require(specifier);
|
||||
},
|
||||
setTimeout,
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, sandbox, { filename: installPath });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.match(
|
||||
output.stderr,
|
||||
/install failed .*Refusing non-HTTPS download URL \(CBM_DOWNLOAD_URL\): http:\/\/example\.com\/releases/,
|
||||
);
|
||||
assert.equal(observed.exitCode, 1);
|
||||
assert.doesNotMatch(output.stdout, /downloading/);
|
||||
assert.deepEqual(fs.readdirSync(packageDir), []);
|
||||
} finally {
|
||||
fs.rmSync(packageDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -16,6 +16,8 @@ pipx install codebase-memory-mcp
|
||||
|
||||
There is one composition per platform: the graph UI ships in every build, so no variant selection is needed.
|
||||
|
||||
For testing, `CBM_DOWNLOAD_URL` overrides the download base URL (default `https://github.com/DeusData/codebase-memory-mcp/releases/download/v<version>`): the archive and `checksums.txt` are fetched from `<CBM_DOWNLOAD_URL>/<file>`. It must be `https://`, or plain `http://` to exactly `localhost`, `127.0.0.1`, or `[::1]` (optional port; no proxy, no redirects).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Downloads codebase-memory-mcp on first run, then runs its native entry point."""
|
||||
|
||||
import collections
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
@@ -39,26 +38,19 @@ _WINDOWS_ARCHIVE_NAMES = (
|
||||
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
||||
# could otherwise turn a download into an arbitrary-local-file read.
|
||||
_ALLOWED_SCHEMES = frozenset({"https"})
|
||||
# install.sh's loopback pattern: plain HTTP only to an exact loopback
|
||||
# authority, for local smoke tests (CBM_DOWNLOAD_URL).
|
||||
_LOOPBACK_HTTP_URL = re.compile(
|
||||
r"http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?#].*)?"
|
||||
)
|
||||
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||
_MAX_REDIRECTS = 5
|
||||
_NETWORK_TIMEOUT_SECONDS = 120
|
||||
_CANDIDATE_TIMEOUT_SECONDS = 15
|
||||
_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
|
||||
# Release archive resource limits. Keep in sync with the Go wrapper
|
||||
# (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper (install.js).
|
||||
_MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024
|
||||
# Entry-count guard for the archive namespace check. Keep in sync with the
|
||||
# Go wrapper (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper.
|
||||
_MAX_ARCHIVE_MEMBERS = 64
|
||||
_MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024
|
||||
_MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024
|
||||
_ARCHIVE_COPY_CHUNK_BYTES = 64 * 1024
|
||||
_ArchiveLimits = collections.namedtuple(
|
||||
"_ArchiveLimits", "compressed_bytes members member_bytes expanded_bytes"
|
||||
)
|
||||
_DEFAULT_ARCHIVE_LIMITS = _ArchiveLimits(
|
||||
compressed_bytes=_MAX_RELEASE_ARCHIVE_BYTES,
|
||||
members=_MAX_ARCHIVE_MEMBERS,
|
||||
member_bytes=_MAX_ARCHIVE_MEMBER_BYTES,
|
||||
expanded_bytes=_MAX_ARCHIVE_EXPANDED_BYTES,
|
||||
)
|
||||
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
||||
_RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock"
|
||||
_RUNTIME_LOCK_WAIT_SECONDS = 45
|
||||
@@ -79,13 +71,50 @@ _runtime_lock_claim_observer = None
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Expose redirects to _download_https for explicit per-hop validation."""
|
||||
"""Expose redirects to _download_file for explicit per-hop validation."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
||||
# A loopback fetch never goes through a proxy.
|
||||
_LOOPBACK_OPENER = urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({}), _NoRedirectHandler()
|
||||
)
|
||||
|
||||
|
||||
def _download_base(version: str, environ=None):
|
||||
"""Return (base, loopback) for release downloads.
|
||||
|
||||
CBM_DOWNLOAD_URL replaces the release download base (for testing), with
|
||||
install.sh's policy: HTTPS, or plain HTTP to an exact loopback authority.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
override = env.get("CBM_DOWNLOAD_URL")
|
||||
if not override:
|
||||
return f"https://github.com/{REPO}/releases/download/v{version}", False
|
||||
if override.startswith("https://"):
|
||||
return override, False
|
||||
if _LOOPBACK_HTTP_URL.fullmatch(override):
|
||||
return override, True
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: refusing non-HTTPS download URL "
|
||||
f"(CBM_DOWNLOAD_URL): {override}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_loopback_url(url: str) -> None:
|
||||
"""Reject anything but plain HTTP to an exact loopback authority."""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if (
|
||||
not _LOOPBACK_HTTP_URL.fullmatch(url)
|
||||
or parsed.scheme != "http"
|
||||
or parsed.hostname not in _LOOPBACK_HOSTNAMES
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
sys.exit(f"codebase-memory-mcp: refusing non-loopback HTTP URL: {url}")
|
||||
|
||||
|
||||
def _validate_url_scheme(url: str) -> None:
|
||||
@@ -103,23 +132,33 @@ def _validate_url_scheme(url: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
||||
"""Download through a bounded HTTPS-only opener into dest."""
|
||||
def _validate_download_url(url: str, loopback: bool) -> None:
|
||||
if loopback:
|
||||
_validate_loopback_url(url)
|
||||
else:
|
||||
_validate_url_scheme(url)
|
||||
|
||||
|
||||
def _download_file(url: str, dest: str, loopback: bool = False) -> None:
|
||||
"""Download into dest: HTTPS with checked redirects, or one loopback hop."""
|
||||
opener = _LOOPBACK_OPENER if loopback else _HTTPS_OPENER
|
||||
current_url = url
|
||||
for redirect_count in range(_MAX_REDIRECTS + 1):
|
||||
_validate_url_scheme(current_url)
|
||||
_validate_download_url(current_url, loopback)
|
||||
request = urllib.request.Request(
|
||||
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
|
||||
)
|
||||
try:
|
||||
response = _HTTPS_OPENER.open(
|
||||
request, timeout=_NETWORK_TIMEOUT_SECONDS
|
||||
)
|
||||
response = opener.open(request, timeout=_NETWORK_TIMEOUT_SECONDS)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in _REDIRECT_CODES:
|
||||
raise
|
||||
location = exc.headers.get("Location")
|
||||
exc.close()
|
||||
if loopback:
|
||||
raise RuntimeError(
|
||||
f"refusing redirect from loopback download: {current_url}"
|
||||
)
|
||||
if not location:
|
||||
raise RuntimeError(f"redirect has no Location: {current_url}")
|
||||
if redirect_count == _MAX_REDIRECTS:
|
||||
@@ -129,18 +168,8 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
||||
continue
|
||||
|
||||
with response:
|
||||
_validate_url_scheme(response.geturl())
|
||||
declared = (response.headers.get("Content-Length") or "").strip()
|
||||
# Only plain ASCII digits are a length (str.isdigit also accepts
|
||||
# characters that int() rejects); anything else is left to the
|
||||
# byte counter below.
|
||||
if max_bytes and re.fullmatch(r"[0-9]+", declared):
|
||||
if int(declared) > max_bytes:
|
||||
raise RuntimeError(
|
||||
f"download exceeds the {max_bytes}-byte safety limit"
|
||||
)
|
||||
_validate_download_url(response.geturl(), loopback)
|
||||
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
||||
total = 0
|
||||
try:
|
||||
with open(dest, "wb") as out:
|
||||
while True:
|
||||
@@ -151,12 +180,6 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
return
|
||||
total += len(chunk)
|
||||
if max_bytes and total > max_bytes:
|
||||
raise RuntimeError(
|
||||
f"download exceeds the {max_bytes}-byte "
|
||||
f"safety limit"
|
||||
)
|
||||
out.write(chunk)
|
||||
except BaseException:
|
||||
# Never leave a partial download behind for a later step to
|
||||
@@ -236,106 +259,51 @@ def _validate_archive_names(names, archive_names, casefold: bool = False):
|
||||
)
|
||||
|
||||
|
||||
def _validate_archive_limits(limits) -> None:
|
||||
if (
|
||||
limits.compressed_bytes <= 0
|
||||
or limits.members <= 0
|
||||
or limits.member_bytes <= 0
|
||||
or limits.expanded_bytes <= 0
|
||||
):
|
||||
sys.exit("codebase-memory-mcp: invalid archive resource safety limits")
|
||||
|
||||
|
||||
def _require_compressed_archive_within_limit(archive_path: str, limits) -> None:
|
||||
status = os.stat(archive_path)
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
def _require_regular_archive(archive_path: str) -> None:
|
||||
if not stat.S_ISREG(os.stat(archive_path).st_mode):
|
||||
sys.exit("codebase-memory-mcp: release archive is not a regular file")
|
||||
if status.st_size > limits.compressed_bytes:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: release archive exceeds the "
|
||||
f"{limits.compressed_bytes}-byte compressed safety limit"
|
||||
)
|
||||
|
||||
|
||||
def _require_member_count_within_limit(count: int, limits) -> None:
|
||||
if count > limits.members:
|
||||
def _require_member_count_within_limit(count: int, max_members: int) -> None:
|
||||
if count > max_members:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive exceeds the {limits.members}-member "
|
||||
f"codebase-memory-mcp: archive exceeds the {max_members}-member "
|
||||
f"safety limit"
|
||||
)
|
||||
|
||||
|
||||
def _account_declared_archive_member(
|
||||
name: str, size: int, declared_expanded: int, limits
|
||||
) -> int:
|
||||
"""Check one member's declared size; return the new declared total."""
|
||||
def _require_declared_size(name: str, size: int) -> None:
|
||||
if size < 0:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive member has a negative declared "
|
||||
f"size: {name!r}"
|
||||
)
|
||||
if size > limits.member_bytes:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive member {name!r} exceeds the "
|
||||
f"{limits.member_bytes}-byte expanded safety limit"
|
||||
)
|
||||
if declared_expanded > limits.expanded_bytes - size:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte "
|
||||
f"aggregate expanded safety limit"
|
||||
)
|
||||
return declared_expanded + size
|
||||
|
||||
|
||||
def _copy_archive_member_within_limits(
|
||||
source, output, name: str, declared_size: int, actual_expanded: int, limits
|
||||
) -> int:
|
||||
"""Copy a member while counting bytes; return the new actual total.
|
||||
def _copy_archive_member(source, output, name: str, declared_size: int) -> int:
|
||||
"""Copy a member, then require the declared size; return the bytes copied.
|
||||
|
||||
Reads stop one byte past the smaller of the member and remaining aggregate
|
||||
limits, so a member that yields more than it declares can never fill the
|
||||
disk. The caller removes the target on failure.
|
||||
The caller removes the target on failure.
|
||||
"""
|
||||
remaining = limits.expanded_bytes - actual_expanded
|
||||
copy_limit = min(limits.member_bytes, remaining)
|
||||
copied = 0
|
||||
while True:
|
||||
wanted = min(_ARCHIVE_COPY_CHUNK_BYTES, copy_limit + 1 - copied)
|
||||
if wanted <= 0:
|
||||
break
|
||||
chunk = source.read(wanted)
|
||||
chunk = source.read(_ARCHIVE_COPY_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
copied += len(chunk)
|
||||
if copied > copy_limit:
|
||||
break
|
||||
output.write(chunk)
|
||||
if copied > limits.member_bytes:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive member {name!r} exceeds the "
|
||||
f"{limits.member_bytes}-byte actual expanded safety limit"
|
||||
)
|
||||
if copied > remaining:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte "
|
||||
f"aggregate actual expanded safety limit"
|
||||
)
|
||||
if copied != declared_size:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: archive member {name!r} actual size {copied} "
|
||||
f"does not match declared size {declared_size}"
|
||||
)
|
||||
return actual_expanded + copied
|
||||
return copied
|
||||
|
||||
|
||||
def _write_archive_member_within_limits(
|
||||
source, target: str, name: str, declared_size: int, actual_expanded: int, limits
|
||||
) -> int:
|
||||
def _write_archive_member(source, target: str, name: str, declared_size: int) -> int:
|
||||
try:
|
||||
with open(target, "xb") as output:
|
||||
return _copy_archive_member_within_limits(
|
||||
source, output, name, declared_size, actual_expanded, limits
|
||||
)
|
||||
return _copy_archive_member(source, output, name, declared_size)
|
||||
except BaseException:
|
||||
_remove_quietly(target)
|
||||
raise
|
||||
@@ -347,36 +315,29 @@ def _safe_extract_tar(
|
||||
archive_names=(),
|
||||
extract_names=(),
|
||||
ui: bool = False,
|
||||
limits=_DEFAULT_ARCHIVE_LIMITS,
|
||||
max_members: int = _MAX_ARCHIVE_MEMBERS,
|
||||
):
|
||||
"""Validate a tar namespace, then extract only its authenticated runtime set."""
|
||||
_validate_archive_limits(limits)
|
||||
members = tf.getmembers()
|
||||
_require_member_count_within_limit(len(members), limits)
|
||||
declared_expanded = 0
|
||||
_require_member_count_within_limit(len(members), max_members)
|
||||
for member in members:
|
||||
if not member.isfile():
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: refusing unsafe tar entry "
|
||||
f"(not a regular root file: {member.name!r})"
|
||||
)
|
||||
declared_expanded = _account_declared_archive_member(
|
||||
member.name, member.size, declared_expanded, limits
|
||||
)
|
||||
_require_declared_size(member.name, member.size)
|
||||
_validate_archive_names([member.name for member in members], archive_names)
|
||||
runtime_names = list(extract_names)
|
||||
by_name = {member.name: member for member in members}
|
||||
dest_abs = os.path.abspath(dest)
|
||||
actual_expanded = 0
|
||||
for name in runtime_names:
|
||||
source = tf.extractfile(by_name[name])
|
||||
if source is None:
|
||||
sys.exit(f"codebase-memory-mcp: could not read archive member: {name}")
|
||||
target = os.path.join(dest_abs, name)
|
||||
with source:
|
||||
actual_expanded = _write_archive_member_within_limits(
|
||||
source, target, name, by_name[name].size, actual_expanded, limits
|
||||
)
|
||||
_write_archive_member(source, target, name, by_name[name].size)
|
||||
return tuple(runtime_names)
|
||||
|
||||
|
||||
@@ -386,7 +347,7 @@ def _safe_extract_zip(
|
||||
archive_names=(),
|
||||
extract_names=(),
|
||||
ui: bool = False,
|
||||
limits=_DEFAULT_ARCHIVE_LIMITS,
|
||||
max_members: int = _MAX_ARCHIVE_MEMBERS,
|
||||
):
|
||||
"""Extract a zipfile after validating its Windows-style namespace.
|
||||
|
||||
@@ -394,16 +355,12 @@ def _safe_extract_zip(
|
||||
members before extraction prevents archive order from selecting the binary
|
||||
that a portable package-manager shim eventually executes.
|
||||
"""
|
||||
_validate_archive_limits(limits)
|
||||
dest_abs = os.path.abspath(dest)
|
||||
names = []
|
||||
infos = zf.infolist()
|
||||
_require_member_count_within_limit(len(infos), limits)
|
||||
declared_expanded = 0
|
||||
_require_member_count_within_limit(len(infos), max_members)
|
||||
for info in infos:
|
||||
declared_expanded = _account_declared_archive_member(
|
||||
info.filename, info.file_size, declared_expanded, limits
|
||||
)
|
||||
_require_declared_size(info.filename, info.file_size)
|
||||
raw_name = info.filename
|
||||
name = raw_name.replace("\\", "/")
|
||||
segments_name = name[:-1] if name.endswith("/") else name
|
||||
@@ -437,30 +394,24 @@ def _safe_extract_zip(
|
||||
# Extract only the validated runtime files. This avoids relying on
|
||||
# platform-specific zip path rewriting and always creates regular files.
|
||||
runtime_names = list(extract_names)
|
||||
actual_expanded = 0
|
||||
for name in runtime_names:
|
||||
target = os.path.join(dest_abs, name)
|
||||
with zf.open(name) as source:
|
||||
actual_expanded = _write_archive_member_within_limits(
|
||||
source,
|
||||
target,
|
||||
name,
|
||||
zf.getinfo(name).file_size,
|
||||
actual_expanded,
|
||||
limits,
|
||||
)
|
||||
_write_archive_member(source, target, name, zf.getinfo(name).file_size)
|
||||
return tuple(runtime_names)
|
||||
|
||||
|
||||
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
|
||||
"""Verify SHA256 checksum against checksums.txt from the release."""
|
||||
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
|
||||
def _verify_checksum(
|
||||
archive_path: str, archive_name: str, base: str, loopback: bool = False
|
||||
) -> None:
|
||||
"""Verify SHA256 checksum against checksums.txt from the same base."""
|
||||
url = f"{base}/checksums.txt"
|
||||
tmp_path = None
|
||||
try:
|
||||
_validate_url_scheme(url)
|
||||
_validate_download_url(url, loopback)
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
_download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
|
||||
_download_file(url, tmp_path, loopback)
|
||||
expected = None
|
||||
with open(tmp_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
@@ -1344,8 +1295,9 @@ def _download(version: str) -> Path:
|
||||
# have no such variant. Keep in sync with install.sh / install.js / cli.c.
|
||||
portable = "-portable" if os_name == "linux" else ""
|
||||
archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
|
||||
url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
|
||||
_validate_url_scheme(url)
|
||||
base, loopback = _download_base(version)
|
||||
url = f"{base}/{archive}"
|
||||
_validate_download_url(url, loopback)
|
||||
|
||||
dest = _bin_path(version)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1358,7 +1310,7 @@ def _download(version: str) -> Path:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
||||
try:
|
||||
_download_https(url, tmp_archive, _MAX_RELEASE_ARCHIVE_BYTES)
|
||||
_download_file(url, tmp_archive, loopback)
|
||||
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: download failed ({e})\n"
|
||||
@@ -1366,10 +1318,8 @@ def _download(version: str) -> Path:
|
||||
f"See https://github.com/{REPO}/releases for available versions."
|
||||
)
|
||||
|
||||
_verify_checksum(tmp_archive, archive, version)
|
||||
_require_compressed_archive_within_limit(
|
||||
tmp_archive, _DEFAULT_ARCHIVE_LIMITS
|
||||
)
|
||||
_verify_checksum(tmp_archive, archive, base, loopback)
|
||||
_require_regular_archive(tmp_archive)
|
||||
|
||||
bin_name = (
|
||||
_WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
|
||||
|
||||
+220
-153
@@ -1,5 +1,7 @@
|
||||
import contextlib
|
||||
import errno
|
||||
import hashlib
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -840,9 +842,10 @@ _cli._publish_runtime_set(
|
||||
class _FakeHttpsResponse:
|
||||
"""Minimal stand-in for the opener's response: headers, geturl, read."""
|
||||
|
||||
def __init__(self, body: bytes, headers=None):
|
||||
def __init__(self, body: bytes, headers=None, fail_after_first_chunk=False):
|
||||
self._body = body
|
||||
self._offset = 0
|
||||
self._fail_after_first_chunk = fail_after_first_chunk
|
||||
self.headers = dict(headers or {})
|
||||
|
||||
def __enter__(self):
|
||||
@@ -855,6 +858,8 @@ class _FakeHttpsResponse:
|
||||
return "https://example.invalid/release.tar.gz"
|
||||
|
||||
def read(self, size=-1):
|
||||
if self._fail_after_first_chunk and self._offset:
|
||||
raise OSError("connection reset")
|
||||
if size is None or size < 0:
|
||||
size = len(self._body) - self._offset
|
||||
chunk = self._body[self._offset : self._offset + size]
|
||||
@@ -862,8 +867,31 @@ class _FakeHttpsResponse:
|
||||
return chunk
|
||||
|
||||
|
||||
def _limits_with(**overrides):
|
||||
return _cli._DEFAULT_ARCHIVE_LIMITS._replace(**overrides)
|
||||
class _ZeroSource:
|
||||
"""Reader that yields `size` zero bytes without holding them."""
|
||||
|
||||
def __init__(self, size: int):
|
||||
self._remaining = size
|
||||
|
||||
def read(self, size=-1):
|
||||
if size is None or size < 0:
|
||||
size = self._remaining
|
||||
count = min(size, self._remaining)
|
||||
self._remaining -= count
|
||||
return bytes(count)
|
||||
|
||||
|
||||
class _CountingSink:
|
||||
def __init__(self):
|
||||
self.written = 0
|
||||
|
||||
def write(self, chunk):
|
||||
self.written += len(chunk)
|
||||
return len(chunk)
|
||||
|
||||
|
||||
# The size cap the 0.12.0 wrappers enforced, which refused every release.
|
||||
_FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024
|
||||
|
||||
|
||||
def _write_tar_gz(archive, entries):
|
||||
@@ -884,59 +912,33 @@ def _release_entries(names):
|
||||
return [(name, f"payload of {name}".encode()) for name in names]
|
||||
|
||||
|
||||
class ArchiveLimitTests(unittest.TestCase):
|
||||
class ArchiveExtractionTests(unittest.TestCase):
|
||||
URL = "https://example.invalid/release.tar.gz"
|
||||
|
||||
def _download(self, response, destination, max_bytes):
|
||||
def _download(self, response, destination):
|
||||
with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response):
|
||||
_cli._download_https(self.URL, destination, max_bytes)
|
||||
_cli._download_file(self.URL, destination)
|
||||
|
||||
def test_download_rejects_a_declared_length_over_the_limit(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
destination = os.path.join(root, "release.tar.gz")
|
||||
response = _FakeHttpsResponse(b"", {"Content-Length": "9"})
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self._download(response, destination, 8)
|
||||
self.assertIn("8-byte safety limit", str(raised.exception))
|
||||
self.assertFalse(os.path.exists(destination))
|
||||
|
||||
def test_download_rejects_a_body_over_the_limit_and_removes_the_partial_file(
|
||||
def test_download_stores_a_body_whose_declared_length_exceeds_the_former_cap(
|
||||
self,
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
destination = os.path.join(root, "release.tar.gz")
|
||||
response = _FakeHttpsResponse(b"x" * 1024)
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self._download(response, destination, 8)
|
||||
self.assertIn("8-byte safety limit", str(raised.exception))
|
||||
response = _FakeHttpsResponse(
|
||||
b"payload", {"Content-Length": str(_FORMER_MEMBER_CAP_BYTES + 1)}
|
||||
)
|
||||
self._download(response, destination)
|
||||
with open(destination, "rb") as stored:
|
||||
self.assertEqual(stored.read(), b"payload")
|
||||
|
||||
def test_download_removes_the_partial_file_when_the_body_fails(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
destination = os.path.join(root, "release.tar.gz")
|
||||
response = _FakeHttpsResponse(b"x" * 200000, fail_after_first_chunk=True)
|
||||
with self.assertRaises(OSError):
|
||||
self._download(response, destination)
|
||||
self.assertFalse(os.path.exists(destination))
|
||||
|
||||
def test_download_within_the_limit_stores_the_body(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
destination = os.path.join(root, "release.tar.gz")
|
||||
response = _FakeHttpsResponse(b"payload", {"Content-Length": "7"})
|
||||
self._download(response, destination, 1024)
|
||||
with open(destination, "rb") as stored:
|
||||
self.assertEqual(stored.read(), b"payload")
|
||||
|
||||
def test_download_counts_bytes_when_the_declared_length_is_not_a_number(self):
|
||||
# A superscript two passes str.isdigit but not int(); such values and
|
||||
# plain junk must fall through to the byte counter, never crash.
|
||||
for declared in ("²", "abc", "", "1e3", "-1"):
|
||||
with self.subTest(declared=declared), tempfile.TemporaryDirectory() as root:
|
||||
destination = os.path.join(root, "release.tar.gz")
|
||||
response = _FakeHttpsResponse(b"x" * 1024, {"Content-Length": declared})
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self._download(response, destination, 8)
|
||||
self.assertIn("8-byte safety limit", str(raised.exception))
|
||||
self.assertFalse(os.path.exists(destination))
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
destination = os.path.join(root, "release.tar.gz")
|
||||
response = _FakeHttpsResponse(b"payload", {"Content-Length": "²"})
|
||||
self._download(response, destination, 1024)
|
||||
with open(destination, "rb") as stored:
|
||||
self.assertEqual(stored.read(), b"payload")
|
||||
|
||||
def test_unix_tar_rejects_more_members_than_the_limit(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
archive = Path(root) / "members.tar.gz"
|
||||
@@ -949,50 +951,12 @@ class ArchiveLimitTests(unittest.TestCase):
|
||||
str(destination),
|
||||
("one", "two", "three"),
|
||||
("one",),
|
||||
limits=_limits_with(members=2),
|
||||
max_members=2,
|
||||
)
|
||||
self.assertIn("2-member safety limit", str(raised.exception))
|
||||
self.assertFalse((destination / "one").exists())
|
||||
|
||||
def test_unix_tar_rejects_a_member_declared_over_the_limit(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
archive = Path(root) / "member.tar.gz"
|
||||
_write_tar_gz(archive, [("codebase-memory-mcp", b"x" * 65)])
|
||||
destination = Path(root) / "extract"
|
||||
destination.mkdir()
|
||||
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
|
||||
_cli._safe_extract_tar(
|
||||
tf,
|
||||
str(destination),
|
||||
("codebase-memory-mcp",),
|
||||
("codebase-memory-mcp",),
|
||||
limits=_limits_with(member_bytes=64),
|
||||
)
|
||||
self.assertIn(
|
||||
"'codebase-memory-mcp' exceeds the 64-byte expanded safety limit",
|
||||
str(raised.exception),
|
||||
)
|
||||
self.assertFalse((destination / "codebase-memory-mcp").exists())
|
||||
|
||||
def test_unix_tar_rejects_a_declared_aggregate_over_the_limit(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
archive = Path(root) / "aggregate.tar.gz"
|
||||
_write_tar_gz(archive, [("one", b"a" * 40), ("two", b"b" * 40)])
|
||||
destination = Path(root) / "extract"
|
||||
destination.mkdir()
|
||||
with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised:
|
||||
_cli._safe_extract_tar(
|
||||
tf,
|
||||
str(destination),
|
||||
("one", "two"),
|
||||
("one",),
|
||||
limits=_limits_with(member_bytes=64, expanded_bytes=64),
|
||||
)
|
||||
self.assertIn(
|
||||
"64-byte aggregate expanded safety limit", str(raised.exception)
|
||||
)
|
||||
|
||||
def test_unix_tar_within_the_limits_extracts_only_the_executable(self):
|
||||
def test_unix_tar_extracts_only_the_executable(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
archive = Path(root) / "release.tar.gz"
|
||||
_write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES))
|
||||
@@ -1012,67 +976,29 @@ class ArchiveLimitTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"])
|
||||
|
||||
def test_archive_copy_stops_a_member_that_yields_more_than_the_limit(self):
|
||||
source = io.BytesIO(b"x" * 100)
|
||||
output = io.BytesIO()
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
_cli._copy_archive_member_within_limits(
|
||||
source,
|
||||
output,
|
||||
"member",
|
||||
100,
|
||||
0,
|
||||
_limits_with(member_bytes=64, expanded_bytes=1024),
|
||||
)
|
||||
self.assertIn(
|
||||
"'member' exceeds the 64-byte actual expanded safety limit",
|
||||
str(raised.exception),
|
||||
def test_archive_copy_accepts_a_member_larger_than_the_former_cap(self):
|
||||
size = _FORMER_MEMBER_CAP_BYTES + 1024 * 1024
|
||||
sink = _CountingSink()
|
||||
copied = _cli._copy_archive_member(
|
||||
_ZeroSource(size), sink, "codebase-memory-mcp", size
|
||||
)
|
||||
# Reading stopped one byte past the limit; nothing beyond it was kept.
|
||||
self.assertEqual(source.tell(), 65)
|
||||
self.assertLessEqual(len(output.getvalue()), 64)
|
||||
self.assertEqual(copied, size)
|
||||
self.assertEqual(sink.written, size)
|
||||
|
||||
def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self):
|
||||
source = io.BytesIO(b"x" * 100)
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
_cli._copy_archive_member_within_limits(
|
||||
source,
|
||||
io.BytesIO(),
|
||||
"member",
|
||||
10,
|
||||
0,
|
||||
_limits_with(member_bytes=1024, expanded_bytes=1024),
|
||||
)
|
||||
_cli._copy_archive_member(io.BytesIO(b"x" * 100), io.BytesIO(), "member", 10)
|
||||
self.assertIn(
|
||||
"'member' actual size 100 does not match declared size 10",
|
||||
str(raised.exception),
|
||||
)
|
||||
|
||||
def test_archive_copy_rejects_an_aggregate_over_the_actual_limit(self):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
_cli._copy_archive_member_within_limits(
|
||||
io.BytesIO(b"abcd"),
|
||||
io.BytesIO(),
|
||||
"member",
|
||||
4,
|
||||
3,
|
||||
_limits_with(member_bytes=10, expanded_bytes=5),
|
||||
)
|
||||
self.assertIn(
|
||||
"5-byte aggregate actual expanded safety limit", str(raised.exception)
|
||||
)
|
||||
|
||||
def test_archive_member_write_removes_the_partial_file_on_failure(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
target = os.path.join(root, "codebase-memory-mcp")
|
||||
with self.assertRaises(SystemExit):
|
||||
_cli._write_archive_member_within_limits(
|
||||
io.BytesIO(b"x" * 100),
|
||||
target,
|
||||
"codebase-memory-mcp",
|
||||
10,
|
||||
0,
|
||||
_limits_with(member_bytes=1024, expanded_bytes=1024),
|
||||
_cli._write_archive_member(
|
||||
io.BytesIO(b"x" * 100), target, "codebase-memory-mcp", 10
|
||||
)
|
||||
self.assertFalse(os.path.exists(target))
|
||||
|
||||
@@ -1088,28 +1014,11 @@ class ArchiveLimitTests(unittest.TestCase):
|
||||
str(destination),
|
||||
("one", "two", "three"),
|
||||
("one",),
|
||||
limits=_limits_with(members=2),
|
||||
max_members=2,
|
||||
)
|
||||
self.assertIn("2-member safety limit", str(raised.exception))
|
||||
|
||||
def test_windows_zip_rejects_a_member_declared_over_the_limit(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
archive = Path(root) / "member.zip"
|
||||
_write_zip(archive, [(_cli._WINDOWS_BINARY_NAME, b"x" * 65)])
|
||||
destination = Path(root) / "extract"
|
||||
destination.mkdir()
|
||||
with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised:
|
||||
_cli._safe_extract_zip(
|
||||
zf,
|
||||
str(destination),
|
||||
(_cli._WINDOWS_BINARY_NAME,),
|
||||
(_cli._WINDOWS_BINARY_NAME,),
|
||||
limits=_limits_with(member_bytes=64),
|
||||
)
|
||||
self.assertIn("64-byte expanded safety limit", str(raised.exception))
|
||||
self.assertFalse((destination / _cli._WINDOWS_BINARY_NAME).exists())
|
||||
|
||||
def test_windows_zip_within_the_limits_extracts_only_the_executable(self):
|
||||
def test_windows_zip_extracts_only_the_executable(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
archive = Path(root) / "release.zip"
|
||||
_write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES))
|
||||
@@ -1129,3 +1038,161 @@ class ArchiveLimitTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME])
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _loopback_server(routes):
|
||||
"""Serve `routes` ({path: bytes}) on 127.0.0.1; any other path redirects."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = routes.get(self.path)
|
||||
if body is None:
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "https://example.invalid/elsewhere")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
class DownloadBaseTests(unittest.TestCase):
|
||||
GITHUB_BASE = "https://github.com/DeusData/codebase-memory-mcp/releases/download/v1.2.3"
|
||||
|
||||
def test_unset_or_empty_uses_the_github_release_base(self):
|
||||
self.assertEqual(_cli._download_base("1.2.3", {}), (self.GITHUB_BASE, False))
|
||||
self.assertEqual(
|
||||
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": ""}),
|
||||
(self.GITHUB_BASE, False),
|
||||
)
|
||||
|
||||
def test_https_override_is_accepted(self):
|
||||
for base in (
|
||||
"https://mirror.example/releases/v1",
|
||||
"https://127.0.0.1:8443/fixture",
|
||||
):
|
||||
with self.subTest(base=base):
|
||||
self.assertEqual(
|
||||
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
|
||||
(base, False),
|
||||
)
|
||||
|
||||
def test_exact_loopback_http_override_is_accepted(self):
|
||||
for base in (
|
||||
"http://localhost",
|
||||
"http://localhost:8080/fixture",
|
||||
"http://127.0.0.1:41234/standard",
|
||||
"http://[::1]:9/x?y#z",
|
||||
):
|
||||
with self.subTest(base=base):
|
||||
self.assertEqual(
|
||||
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base}),
|
||||
(base, True),
|
||||
)
|
||||
|
||||
def test_non_loopback_http_and_other_schemes_are_refused(self):
|
||||
for base in (
|
||||
"http://example.com/releases",
|
||||
"http://127.0.0.1.example.com/",
|
||||
"http://localhost.example.com:8080/",
|
||||
"http://user@localhost/",
|
||||
"http://127.0.0.2/",
|
||||
"HTTP://localhost/",
|
||||
"file:///tmp/releases",
|
||||
"ftp://localhost/releases",
|
||||
"github.com/DeusData/codebase-memory-mcp",
|
||||
):
|
||||
with self.subTest(base=base), self.assertRaises(SystemExit) as raised:
|
||||
_cli._download_base("1.2.3", {"CBM_DOWNLOAD_URL": base})
|
||||
self.assertIn(
|
||||
"refusing non-HTTPS download URL (CBM_DOWNLOAD_URL)",
|
||||
str(raised.exception),
|
||||
)
|
||||
|
||||
def test_https_download_refuses_plain_http(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with self.assertRaises(SystemExit):
|
||||
_cli._download_file(
|
||||
"http://127.0.0.1/release.tar.gz", os.path.join(root, "a")
|
||||
)
|
||||
|
||||
def test_loopback_download_refuses_a_non_loopback_url(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
_cli._download_file(
|
||||
"http://example.invalid/release.tar.gz",
|
||||
os.path.join(root, "a"),
|
||||
loopback=True,
|
||||
)
|
||||
self.assertIn("refusing non-loopback HTTP URL", str(raised.exception))
|
||||
|
||||
def test_loopback_download_bypasses_proxies_and_refuses_redirects(self):
|
||||
routes = {"/fixture/archive": b"loopback payload"}
|
||||
# A configured proxy that is not listening: using it would fail.
|
||||
proxy_env = {"http_proxy": "http://127.0.0.1:9", "HTTP_PROXY": "http://127.0.0.1:9"}
|
||||
with _loopback_server(routes) as origin, tempfile.TemporaryDirectory() as root, \
|
||||
mock.patch.dict(os.environ, proxy_env):
|
||||
archive = os.path.join(root, "archive")
|
||||
_cli._download_file(f"{origin}/fixture/archive", archive, loopback=True)
|
||||
with open(archive, "rb") as stored:
|
||||
self.assertEqual(stored.read(), b"loopback payload")
|
||||
|
||||
redirected = os.path.join(root, "redirected")
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
_cli._download_file(
|
||||
f"{origin}/fixture/checksums.txt", redirected, loopback=True
|
||||
)
|
||||
self.assertIn("refusing redirect from loopback download", str(raised.exception))
|
||||
self.assertFalse(os.path.exists(redirected))
|
||||
|
||||
def test_download_fetches_archive_and_checksums_from_the_override(self):
|
||||
os_name = _cli._os_name()
|
||||
arch = _cli._arch()
|
||||
if os_name == "windows":
|
||||
ext, names = "zip", _cli._WINDOWS_ARCHIVE_NAMES
|
||||
else:
|
||||
ext, names = "tar.gz", _cli._UNIX_ARCHIVE_NAMES
|
||||
portable = "-portable" if os_name == "linux" else ""
|
||||
archive_name = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}"
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
fixture = Path(root) / archive_name
|
||||
if ext == "zip":
|
||||
_write_zip(fixture, _release_entries(names))
|
||||
else:
|
||||
_write_tar_gz(fixture, _release_entries(names))
|
||||
archive_bytes = fixture.read_bytes()
|
||||
digest = hashlib.sha256(archive_bytes).hexdigest()
|
||||
routes = {
|
||||
f"/fixture/{archive_name}": archive_bytes,
|
||||
"/fixture/checksums.txt": f"{digest} {archive_name}\n".encode(),
|
||||
}
|
||||
cache = Path(root) / "cache"
|
||||
with _loopback_server(routes) as origin, mock.patch.dict(
|
||||
os.environ, {"CBM_DOWNLOAD_URL": f"{origin}/fixture"}
|
||||
), mock.patch.object(_cli, "_cache_dir", return_value=cache), \
|
||||
mock.patch.object(_cli, "_verify_candidate"), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
installed = _cli._download("9.9.9")
|
||||
binary_name = (
|
||||
_cli._WINDOWS_BINARY_NAME
|
||||
if os_name == "windows"
|
||||
else "codebase-memory-mcp"
|
||||
)
|
||||
self.assertEqual(
|
||||
installed.read_bytes(), f"payload of {binary_name}".encode()
|
||||
)
|
||||
@@ -128,7 +128,7 @@ SMOKE = {"scripts/smoke-test.sh", "scripts/smoke-local.sh",
|
||||
"scripts/smoke-fixture-server.py", "scripts/smoke-invariants.sh",
|
||||
"test-infrastructure/vm/vm-smoke.sh",
|
||||
"test-infrastructure/vm/windows-user-path-guard.ps1",
|
||||
"scripts/test-windows.ps1"}
|
||||
"scripts/test-windows.ps1", "scripts/smoke-wrappers.sh"}
|
||||
LINT_CONFIG = {"scripts/memory-core-baseline.txt", "scripts/lint-memory-core.py",
|
||||
"scripts/hooks/pre-commit"}
|
||||
NON_GATING = {"scripts/package-release.sh", "scripts/benchmark-index.sh",
|
||||
|
||||
@@ -3996,6 +3996,18 @@ else
|
||||
echo "SKIP Phase 13: no install script available for this platform"
|
||||
fi
|
||||
|
||||
# ── Phase 13w: package-wrapper install E2E ──
|
||||
# The npm, PyPI and Go wrappers install the same release archive as above,
|
||||
# from this checkout, the way a user's package manager runs them (#2657: a
|
||||
# wrapper cap refused every shipped binary). SMOKE_REQUIRE_WRAPPERS=1 (CI)
|
||||
# makes a missing node/python/go toolchain a failure, not a skip.
|
||||
echo ""
|
||||
echo "--- Phase 13w: package-wrapper install E2E ---"
|
||||
if ! bash "$REPO_ROOT/scripts/smoke-wrappers.sh" "$SMOKE_DOWNLOAD_URL"; then
|
||||
echo "FAIL 13w: a package wrapper could not install the release binary"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
else
|
||||
echo ""
|
||||
echo "=== Phase 12-13: SKIPPED (SMOKE_DOWNLOAD_URL not set) ==="
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# smoke-wrappers.sh — install the npm, PyPI and Go wrappers from this checkout
|
||||
# against a release fixture and run the binary each one installed.
|
||||
#
|
||||
# The wrappers download the release archive themselves. This runs each one the
|
||||
# way a user's package manager does, against CBM_DOWNLOAD_URL (the test
|
||||
# override install.sh honours too) serving the real release archives, so a
|
||||
# wrapper that cannot install what we ship fails here instead of on users
|
||||
# (#2657: a 256 MiB archive-member cap against 331 MB binaries).
|
||||
#
|
||||
# Usage: scripts/smoke-wrappers.sh DOWNLOAD_URL [npm] [pypi] [go]
|
||||
# DOWNLOAD_URL base URL serving the release archives and checksums.txt:
|
||||
# https, or plain http to a loopback authority
|
||||
# (no wrapper names = all three)
|
||||
# Environment:
|
||||
# SMOKE_REQUIRE_WRAPPERS=1 a missing toolchain fails instead of skipping (CI)
|
||||
# Exit: 0 = every wrapper installed and ran its binary (or was skipped) ·
|
||||
# 1 = a wrapper failed · 2 = usage error.
|
||||
set -euo pipefail
|
||||
|
||||
case "${BASH_SOURCE[0]}" in */*) cd "${BASH_SOURCE[0]%/*}/.." ;; *) cd .. ;; esac
|
||||
ROOT="$PWD"
|
||||
URL="${1:-}"
|
||||
[ -n "$URL" ] || { sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
|
||||
shift
|
||||
WRAPPERS=("$@")
|
||||
[ ${#WRAPPERS[@]} -gt 0 ] || WRAPPERS=(npm pypi go)
|
||||
REQUIRE="${SMOKE_REQUIRE_WRAPPERS:-0}"
|
||||
EXE=""
|
||||
case "${OSTYPE:-}" in msys* | cygwin* | win32*) EXE=".exe" ;; esac
|
||||
|
||||
WORK="${TMPDIR:-/tmp}/cbm-smoke-wrappers.$$.$RANDOM"
|
||||
mkdir -m 700 "$WORK"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
FAILED=0
|
||||
|
||||
missing() { # wrapper, what
|
||||
if [ "$REQUIRE" = "1" ]; then
|
||||
echo "FAIL $1: SMOKE_REQUIRE_WRAPPERS=1 but $2 is not available"
|
||||
FAILED=1
|
||||
else
|
||||
echo "SKIP $1: $2 not available on this host"
|
||||
fi
|
||||
}
|
||||
|
||||
# A fresh HOME, cache and profile per wrapper: nothing a wrapper writes leaks
|
||||
# into the next one, and a binary cached by an earlier run cannot stand in.
|
||||
isolated() { # wrapper dir, command...
|
||||
local dir="$1"
|
||||
shift
|
||||
mkdir -p "$dir/home" "$dir/cache" "$dir/localappdata"
|
||||
HOME="$dir/home" USERPROFILE="$dir/home" XDG_CACHE_HOME="$dir/cache" \
|
||||
LOCALAPPDATA="$dir/localappdata" CBM_CACHE_DIR="$dir/cbm-cache" \
|
||||
CBM_DOWNLOAD_URL="$URL" "$@"
|
||||
}
|
||||
|
||||
ran() { # wrapper, output file, rc
|
||||
# The binary's own answer ("codebase-memory-mcp 0.12.0"), not a wrapper
|
||||
# message ("codebase-memory-mcp: downloading ..."), proves it ran.
|
||||
if [ "$3" -eq 0 ] && grep -E -q '^codebase-memory-mcp [^:]+$' "$2"; then
|
||||
echo "OK $1: installed and ran the release binary: $(grep -E '^codebase-memory-mcp [^:]+$' "$2" | tail -1)"
|
||||
else
|
||||
echo "FAIL $1: the installed wrapper did not run the release binary (rc=$3):"
|
||||
sed 's/^/ /' "$2" | tail -20
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
smoke_npm() {
|
||||
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
|
||||
missing npm "node/npm"
|
||||
return
|
||||
fi
|
||||
local dir="$WORK/npm" tgz="" line modules rc=0
|
||||
mkdir -p "$dir/prefix"
|
||||
(cd "$ROOT/pkg/npm" && npm pack --silent --pack-destination "$dir") >"$dir/pack.out"
|
||||
while IFS= read -r line; do [ -n "$line" ] && tgz="$line"; done <"$dir/pack.out"
|
||||
# A global install from the packed tarball runs postinstall (install.js)
|
||||
# exactly as `npm i -g codebase-memory-mcp` does.
|
||||
isolated "$dir" npm install --global --prefix "$dir/prefix" --cache "$dir/cache/npm" \
|
||||
--foreground-scripts --no-audit --no-fund "$dir/$tgz" >"$dir/install.log" 2>&1 || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
ran npm "$dir/install.log" "$rc"
|
||||
return
|
||||
fi
|
||||
local modules
|
||||
npm root --global --prefix "$dir/prefix" >"$dir/root.out"
|
||||
IFS= read -r modules <"$dir/root.out"
|
||||
isolated "$dir" node "$modules/codebase-memory-mcp/bin.js" --version >"$dir/run.log" 2>&1 ||
|
||||
rc=$?
|
||||
ran npm "$dir/run.log" "$rc"
|
||||
}
|
||||
|
||||
smoke_pypi() {
|
||||
local py=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
py=python3
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
py=python
|
||||
fi
|
||||
if [ -z "$py" ]; then
|
||||
missing pypi "python3"
|
||||
return
|
||||
fi
|
||||
local dir="$WORK/pypi" rc=0
|
||||
mkdir -p "$dir"
|
||||
"$py" -m venv "$dir/venv"
|
||||
local vpy="$dir/venv/bin/python"
|
||||
[ -x "$vpy" ] || vpy="$dir/venv/Scripts/python.exe"
|
||||
"$vpy" -m pip install --quiet --disable-pip-version-check "$ROOT/pkg/pypi" \
|
||||
>"$dir/install.log" 2>&1 || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
ran pypi "$dir/install.log" "$rc"
|
||||
return
|
||||
fi
|
||||
# The console script's entry point; the first run downloads the binary.
|
||||
isolated "$dir" "$vpy" -c \
|
||||
'import sys, codebase_memory_mcp; sys.argv = ["codebase-memory-mcp", "--version"]; codebase_memory_mcp.main()' \
|
||||
>"$dir/run.log" 2>&1 || rc=$?
|
||||
ran pypi "$dir/run.log" "$rc"
|
||||
}
|
||||
|
||||
smoke_go() {
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
missing go "go"
|
||||
return
|
||||
fi
|
||||
local dir="$WORK/go" rc=0
|
||||
mkdir -p "$dir"
|
||||
(cd "$ROOT/pkg/go" && GOTOOLCHAIN="${GOTOOLCHAIN:-auto}" \
|
||||
go build -o "$dir/codebase-memory-mcp$EXE" ./cmd/codebase-memory-mcp) \
|
||||
>"$dir/install.log" 2>&1 || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
ran go "$dir/install.log" "$rc"
|
||||
return
|
||||
fi
|
||||
# The first run downloads the binary into the wrapper's cache.
|
||||
isolated "$dir" "$dir/codebase-memory-mcp$EXE" --version >"$dir/run.log" 2>&1 || rc=$?
|
||||
ran go "$dir/run.log" "$rc"
|
||||
}
|
||||
|
||||
for wrapper in "${WRAPPERS[@]}"; do
|
||||
case "$wrapper" in
|
||||
npm) smoke_npm ;;
|
||||
pypi) smoke_pypi ;;
|
||||
go) smoke_go ;;
|
||||
*) echo "unknown wrapper: $wrapper" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
exit "$FAILED"
|
||||
@@ -108,7 +108,7 @@ SURFACES=(
|
||||
"pkg/chocolatey/codebase-memory-mcp.nuspec|release"
|
||||
"pkg/chocolatey/tools/chocolateyInstall.ps1|release"
|
||||
"pkg/scoop/codebase-memory-mcp.json|release"
|
||||
"pkg/homebrew/Formula/codebase-memory-mcp.rb|pin:0.10.3"
|
||||
"pkg/homebrew/Formula/codebase-memory-mcp.rb|release"
|
||||
"pkg/aur/PKGBUILD|pin:0.8.1"
|
||||
"pkg/aur/.SRCINFO|pin:0.8.1"
|
||||
)
|
||||
@@ -118,7 +118,6 @@ SURFACES=(
|
||||
# (gh release download v<X.Y.Z> --pattern checksums.txt), replace the version
|
||||
# AND every sha256 it pins, then move the entry to "release" above.
|
||||
PIN_REASONS=(
|
||||
"pkg/homebrew/Formula/codebase-memory-mcp.rb|pins 4 per-asset sha256 (darwin/linux x arm/intel); last re-pinned for v0.10.3"
|
||||
"pkg/aur/PKGBUILD|pins sha256sums_x86_64 + sha256sums_aarch64; last re-pinned for v0.8.1"
|
||||
"pkg/aur/.SRCINFO|generated from PKGBUILD, so it must move with it, not before it"
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user