mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
Every packaging surface states the newest v-prefixed tag, as the version-metadata contract requires: the Chocolatey nuspec and installer (checksum re-pinned to the released windows-amd64 archive), the Go installer, the Nix flake, server.json, the npm and PyPI wrappers. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2300 lines
60 KiB
Go
2300 lines
60 KiB
Go
// codebase-memory-mcp — Go installer wrapper.
|
|
//
|
|
// On first run, downloads the pre-built binary for the current platform from
|
|
// GitHub Releases, caches it, and replaces the current process with it.
|
|
// Subsequent runs skip directly to exec.
|
|
//
|
|
// Install:
|
|
//
|
|
// go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest
|
|
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
repo = "DeusData/codebase-memory-mcp"
|
|
version = "0.11.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)
|
|
|
|
runtimeSetLockName = ".codebase-memory-mcp-runtime.lock"
|
|
runtimeSetLockWait = 45 * time.Second
|
|
runtimeSetOwnerlessStale = 30 * time.Second
|
|
runtimeSetLockLease = 5 * time.Minute
|
|
runtimeSetLockHeartbeat = 20 * time.Second
|
|
runtimeSetLegacyStale = time.Hour
|
|
runtimeSetLockPoll = 25 * time.Millisecond
|
|
runtimeSetLockOwnerFile = "owner.json"
|
|
runtimeSetLockTokenSize = 16
|
|
runtimeBackupPrefix = ".cbm-runtime-backup-"
|
|
runtimeBackupRetired = ".retirement-complete"
|
|
runtimeBackupCleanupOnly = ".cleanup-only"
|
|
runtimeBackupBeforeMarkerEvent = "before-retirement-marker"
|
|
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
|
|
file *os.File
|
|
}
|
|
|
|
type runtimeSetLockOwnerRecord struct {
|
|
PID int `json:"pid"`
|
|
Token string `json:"token"`
|
|
LeaseExpires int64 `json:"lease_expires_ms,omitempty"`
|
|
}
|
|
|
|
type runtimeBackupFile struct {
|
|
path string
|
|
digest [sha256.Size]byte
|
|
}
|
|
|
|
type runtimeBackupTransaction struct {
|
|
path string
|
|
retired bool
|
|
cleanupOnly bool
|
|
files map[string]runtimeBackupFile
|
|
}
|
|
|
|
// Tests use this observer to distinguish a publisher waiting on the runtime
|
|
// lock from one entering the shared publication sequence. Production leaves
|
|
// it nil.
|
|
var (
|
|
runtimeSetLockWaitObserver func()
|
|
runtimeSetLockClaimObserver func() error
|
|
runtimeSetLockCaptureObserver func()
|
|
runtimeSetLockOwnerReadObserver func()
|
|
runtimeBackupCrashObserver func(string, string) error
|
|
runtimeSetProcessAlive = platformRuntimeSetLockProcessAlive
|
|
runtimeMutationSnapshotCleanup = os.RemoveAll
|
|
)
|
|
|
|
func main() {
|
|
mutation := runtimeMutationAction(os.Args[1:])
|
|
if mutation == "update" {
|
|
fmt.Fprintln(
|
|
os.Stderr,
|
|
"This Go wrapper is maintained by Go. Update it with \"go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest\".",
|
|
)
|
|
os.Exit(2)
|
|
}
|
|
executable, err := ensureBinary()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
args := nativeArgs(os.Args[1:])
|
|
if mutation == "install" || mutation == "uninstall" {
|
|
err = execBinaryWithRuntimeLock(executable, args)
|
|
} else {
|
|
err = execBinary(executable, args)
|
|
}
|
|
if err != nil {
|
|
if runtime.GOOS == "windows" {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
printPortableMutationGuidance(args)
|
|
os.Exit(exitErr.ExitCode())
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func ensureBinary() (string, error) {
|
|
binary := binPath()
|
|
ready, err := runtimeSetReadyLocked(
|
|
filepath.Dir(binary), filepath.Base(binary), verifyCandidate,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ready {
|
|
return executionPathForOS(binary, runtime.GOOS), nil
|
|
}
|
|
if err := download(binary); err != nil {
|
|
return "", err
|
|
}
|
|
return executionPathForOS(binary, runtime.GOOS), nil
|
|
}
|
|
|
|
func binPath() string {
|
|
return filepath.Join(
|
|
cacheDir(), version, binaryNameForOS(runtime.GOOS),
|
|
)
|
|
}
|
|
|
|
func binaryNameForOS(targetOS string) string {
|
|
if targetOS == "windows" {
|
|
return windowsBinaryName
|
|
}
|
|
return "codebase-memory-mcp"
|
|
}
|
|
|
|
func executionPathForOS(binary, targetOS string) string {
|
|
return binary
|
|
}
|
|
|
|
func printPortableMutationGuidance(args []string) {
|
|
action := runtimeMutationAction(args)
|
|
if action != "uninstall" {
|
|
return
|
|
}
|
|
packageCommand := "Remove-Item (Get-Command codebase-memory-mcp).Source"
|
|
fmt.Fprintf(
|
|
os.Stderr,
|
|
"This Go Windows copy is portable. Use %q for package maintenance, or run %q once to create a managed installation with coordinated self-update/uninstall.\n",
|
|
packageCommand,
|
|
"codebase-memory-mcp install --yes",
|
|
)
|
|
}
|
|
|
|
func runtimeMutationAction(args []string) string {
|
|
for _, argument := range args {
|
|
switch argument {
|
|
case "--help", "-h", "--version":
|
|
return ""
|
|
case "cli", "hook-augment", "config":
|
|
return ""
|
|
case "install", "update", "uninstall":
|
|
return argument
|
|
}
|
|
if strings.HasPrefix(argument, "-") {
|
|
continue
|
|
}
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func defaultManagedInstallDir() string {
|
|
if runtime.GOOS == "windows" {
|
|
base := os.Getenv("LOCALAPPDATA")
|
|
if base == "" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
base = filepath.Join(home, "AppData", "Local")
|
|
}
|
|
}
|
|
return filepath.Join(base, "Programs", "codebase-memory-mcp")
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, ".local", "bin")
|
|
}
|
|
return filepath.Join(os.TempDir(), "codebase-memory-mcp-managed")
|
|
}
|
|
|
|
func nativeArgs(args []string) []string {
|
|
result := append([]string(nil), args...)
|
|
if runtimeMutationAction(result) != "uninstall" {
|
|
return result
|
|
}
|
|
for _, argument := range result {
|
|
if argument == "--dir" || strings.HasPrefix(argument, "--dir=") {
|
|
return result
|
|
}
|
|
}
|
|
return append(result, "--dir", defaultManagedInstallDir())
|
|
}
|
|
|
|
func mutationTargetDirectory(args []string) (string, bool) {
|
|
for index, argument := range args {
|
|
if strings.HasPrefix(argument, "--dir=") {
|
|
return strings.TrimPrefix(argument, "--dir="), true
|
|
}
|
|
if argument == "--dir" && index+1 < len(args) {
|
|
return args[index+1], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func pathsReferToSameDirectory(left, right string) bool {
|
|
leftAbsolute, leftErr := filepath.Abs(left)
|
|
rightAbsolute, rightErr := filepath.Abs(right)
|
|
if leftErr != nil || rightErr != nil {
|
|
return false
|
|
}
|
|
leftAbsolute = filepath.Clean(leftAbsolute)
|
|
rightAbsolute = filepath.Clean(rightAbsolute)
|
|
if leftAbsolute == rightAbsolute ||
|
|
(runtime.GOOS == "windows" && strings.EqualFold(leftAbsolute, rightAbsolute)) {
|
|
return true
|
|
}
|
|
leftStatus, leftErr := os.Stat(leftAbsolute)
|
|
rightStatus, rightErr := os.Stat(rightAbsolute)
|
|
return leftErr == nil && rightErr == nil &&
|
|
leftStatus.IsDir() && rightStatus.IsDir() &&
|
|
os.SameFile(leftStatus, rightStatus)
|
|
}
|
|
|
|
func mutationSnapshotEnvironment() []string {
|
|
blocked := []string{"CBM_ASSETS_DIR", "CBM_UI_ASSETS_DIR"}
|
|
result := make([]string, 0, len(os.Environ()))
|
|
for _, entry := range os.Environ() {
|
|
separator := strings.IndexByte(entry, '=')
|
|
name := entry
|
|
if separator >= 0 {
|
|
name = entry[:separator]
|
|
}
|
|
drop := false
|
|
for _, blockedName := range blocked {
|
|
if strings.EqualFold(name, blockedName) {
|
|
drop = true
|
|
break
|
|
}
|
|
}
|
|
if !drop {
|
|
result = append(result, entry)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cacheDir() string {
|
|
if d := os.Getenv("CBM_CACHE_DIR"); d != "" {
|
|
return d
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
if d := os.Getenv("LOCALAPPDATA"); d != "" {
|
|
return filepath.Join(d, "codebase-memory-mcp")
|
|
}
|
|
case "darwin":
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, "Library", "Caches", "codebase-memory-mcp")
|
|
}
|
|
}
|
|
if d := os.Getenv("XDG_CACHE_HOME"); d != "" {
|
|
return filepath.Join(d, "codebase-memory-mcp")
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, ".cache", "codebase-memory-mcp")
|
|
}
|
|
return filepath.Join(os.TempDir(), "codebase-memory-mcp")
|
|
}
|
|
|
|
func goos() string {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return "darwin"
|
|
case "linux":
|
|
return "linux"
|
|
case "windows":
|
|
return "windows"
|
|
default:
|
|
return runtime.GOOS
|
|
}
|
|
}
|
|
|
|
func goarch() string {
|
|
switch runtime.GOARCH {
|
|
case "amd64":
|
|
return "amd64"
|
|
case "arm64":
|
|
return "arm64"
|
|
default:
|
|
return runtime.GOARCH
|
|
}
|
|
}
|
|
|
|
func archiveNamesForOS(platform, binaryName string) []string {
|
|
installer := "install.sh"
|
|
if platform == "windows" {
|
|
installer = "install.ps1"
|
|
}
|
|
return []string{
|
|
binaryName,
|
|
"LICENSE",
|
|
installer,
|
|
"THIRD_PARTY_NOTICES.md",
|
|
}
|
|
}
|
|
|
|
func download(dest string) error {
|
|
platform := goos()
|
|
arch := goarch()
|
|
ext := "tar.gz"
|
|
if platform == "windows" {
|
|
ext = "zip"
|
|
}
|
|
|
|
portable := ""
|
|
if platform == "linux" {
|
|
portable = "-portable"
|
|
}
|
|
archive := fmt.Sprintf(
|
|
"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)
|
|
|
|
fmt.Fprintf(os.Stderr, "codebase-memory-mcp: downloading v%s for %s/%s...\n", version, platform, arch)
|
|
|
|
tmp, err := os.MkdirTemp("", "cbm-install-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
archivePath := filepath.Join(tmp, "cbm."+ext)
|
|
if err := httpGet(url, archivePath); 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)
|
|
if err != nil {
|
|
return fmt.Errorf("checksum manifest unavailable: %w", err)
|
|
}
|
|
expected, ok := checksums[archive]
|
|
if !ok {
|
|
return fmt.Errorf("checksum manifest has no entry for %s", archive)
|
|
}
|
|
if err := verifyChecksum(archivePath, expected); err != nil {
|
|
return err
|
|
}
|
|
|
|
binName := binaryNameForOS(platform)
|
|
archiveNames := archiveNamesForOS(platform, binName)
|
|
extractNames := []string{binName}
|
|
var runtimeNames []string
|
|
|
|
if ext == "tar.gz" {
|
|
runtimeNames, err = extractTarGz(
|
|
archivePath, tmp, archiveNames, extractNames,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("extraction failed: %w", err)
|
|
}
|
|
} else {
|
|
runtimeNames, err = extractZip(
|
|
archivePath, tmp, archiveNames, extractNames,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("extraction failed: %w", err)
|
|
}
|
|
}
|
|
for _, name := range runtimeNames {
|
|
extracted := filepath.Join(tmp, name)
|
|
mode := os.FileMode(0644)
|
|
if name == binName {
|
|
mode = 0755
|
|
}
|
|
if err := os.Chmod(extracted, mode); err != nil {
|
|
return fmt.Errorf("could not set candidate permissions for %s: %w", name, err)
|
|
}
|
|
}
|
|
if err := verifyCandidate(filepath.Join(tmp, binName)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return fmt.Errorf("could not create cache dir: %w", err)
|
|
}
|
|
|
|
if err := publishRuntimeSetWithRecovery(
|
|
tmp, filepath.Dir(dest), binName, verifyCandidate,
|
|
); err != nil {
|
|
return fmt.Errorf("could not install runtime set: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateURLScheme rejects non-https URLs before any fetch (defense-in-depth).
|
|
func validateURLScheme(rawURL string) error {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
|
return fmt.Errorf("refusing non-https URL: %s", rawURL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects.
|
|
var httpsOnlyClient = &http.Client{
|
|
Timeout: requestTimeout,
|
|
Transport: &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
TLSHandshakeTimeout: connectTimeout,
|
|
ResponseHeaderTimeout: responseHeaderTimeout,
|
|
ExpectContinueTimeout: time.Second,
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if err := validateURLScheme(req.URL.String()); err != nil {
|
|
return fmt.Errorf("refusing unsafe redirect: %w", err)
|
|
}
|
|
if len(via) > maxRedirects {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func httpGet(rawURL, dest string) error {
|
|
return httpGetWithLimit(rawURL, dest, maxReleaseArchiveSize)
|
|
}
|
|
|
|
func httpGetWithLimit(rawURL, dest string, maxBytes int64) error {
|
|
if err := validateURLScheme(rawURL); err != nil {
|
|
return err
|
|
}
|
|
if maxBytes <= 0 {
|
|
return fmt.Errorf("invalid compressed archive safety limit")
|
|
}
|
|
resp, err := httpsOnlyClient.Get(rawURL) //nolint:gosec
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
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))
|
|
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
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(dest)
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
func fetchChecksums(url string) (map[string]string, error) {
|
|
if err := validateURLScheme(url); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := httpsOnlyClient.Get(url) //nolint:gosec
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumManifestSize+1))
|
|
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)
|
|
if len(parts) == 2 {
|
|
name := parts[1]
|
|
if strings.HasPrefix(name, "*") {
|
|
name = name[1:]
|
|
}
|
|
digest := strings.ToLower(parts[0])
|
|
if len(digest) != sha256.Size*2 {
|
|
return nil, fmt.Errorf("invalid SHA-256 checksum for %s", name)
|
|
}
|
|
if _, err := hex.DecodeString(digest); err != nil {
|
|
return nil, fmt.Errorf("invalid SHA-256 checksum for %s: %w", name, err)
|
|
}
|
|
if prior, exists := result[name]; exists && prior != digest {
|
|
return nil, fmt.Errorf("conflicting SHA-256 checksums for %s", name)
|
|
}
|
|
result[name] = digest
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func verifyChecksum(path, expected string) error {
|
|
expected = strings.ToLower(expected)
|
|
if len(expected) != sha256.Size*2 {
|
|
return fmt.Errorf("invalid SHA-256 checksum length")
|
|
}
|
|
if _, err := hex.DecodeString(expected); err != nil {
|
|
return fmt.Errorf("invalid SHA-256 checksum: %w", err)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return err
|
|
}
|
|
actual := hex.EncodeToString(h.Sum(nil))
|
|
if actual != expected {
|
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateArchiveMemberNames(
|
|
names, archiveNames []string, caseInsensitive bool,
|
|
) error {
|
|
required := make(map[string]struct{}, len(archiveNames))
|
|
for _, name := range archiveNames {
|
|
required[name] = struct{}{}
|
|
}
|
|
seen := make(map[string]struct{}, len(names))
|
|
found := make(map[string]struct{}, len(archiveNames))
|
|
for _, name := range names {
|
|
key := name
|
|
if caseInsensitive {
|
|
key = strings.ToLower(name)
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
return fmt.Errorf(
|
|
"duplicate or case-conflicting archive entry: %q", name,
|
|
)
|
|
}
|
|
seen[key] = struct{}{}
|
|
if _, isRequired := required[name]; !isRequired {
|
|
return fmt.Errorf(
|
|
"archive must contain only the exact root files: %s",
|
|
strings.Join(archiveNames, ", "),
|
|
)
|
|
}
|
|
found[name] = struct{}{}
|
|
}
|
|
if len(seen) != len(archiveNames) || len(found) != len(required) {
|
|
return fmt.Errorf(
|
|
"archive must contain exactly one of each required root file: %s",
|
|
strings.Join(archiveNames, ", "),
|
|
)
|
|
}
|
|
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 {
|
|
status, err := os.Stat(archivePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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,
|
|
) 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),
|
|
)
|
|
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,
|
|
)
|
|
}
|
|
|
|
func tarGzMemberNamesWithLimits(
|
|
archivePath string, limits archiveResourceLimits,
|
|
) ([]string, error) {
|
|
if err := validateArchiveResourceLimits(limits); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireCompressedArchiveWithinLimit(archivePath, limits); err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gz.Close()
|
|
tr := tar.NewReader(gz)
|
|
var names []string
|
|
var declaredExpanded int64
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
|
return nil, fmt.Errorf(
|
|
"archive member is not a regular root file: %q", hdr.Name,
|
|
)
|
|
}
|
|
if len(names) >= limits.members {
|
|
return nil, fmt.Errorf(
|
|
"archive exceeds the %d-member safety limit", limits.members,
|
|
)
|
|
}
|
|
if err := accountDeclaredArchiveMember(
|
|
hdr.Name, hdr.Size, &declaredExpanded, limits,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
names = append(names, hdr.Name)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func extractTarGz(
|
|
archivePath, destDir string,
|
|
archiveNames, extractNames []string,
|
|
) ([]string, error) {
|
|
return extractTarGzWithLimits(
|
|
archivePath,
|
|
destDir,
|
|
archiveNames,
|
|
extractNames,
|
|
defaultArchiveResourceLimits,
|
|
)
|
|
}
|
|
|
|
func extractTarGzWithLimits(
|
|
archivePath, destDir string,
|
|
archiveNames, extractNames []string,
|
|
limits archiveResourceLimits,
|
|
) ([]string, error) {
|
|
names, err := tarGzMemberNamesWithLimits(archivePath, limits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateArchiveMemberNames(names, archiveNames, false); err != nil {
|
|
return nil, err
|
|
}
|
|
runtimeNames := append([]string(nil), extractNames...)
|
|
targets := make(map[string]struct{}, len(runtimeNames))
|
|
for _, name := range runtimeNames {
|
|
targets[name] = struct{}{}
|
|
}
|
|
|
|
f, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gz.Close()
|
|
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 {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
memberCount++
|
|
if memberCount > limits.members {
|
|
return nil, fmt.Errorf(
|
|
"archive exceeds the %d-member safety limit", limits.members,
|
|
)
|
|
}
|
|
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
|
return nil, fmt.Errorf(
|
|
"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(
|
|
outputPath,
|
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
|
0600,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
copyErr := copyArchiveMemberWithLimits(
|
|
out, tr, hdr.Name, hdr.Size, &actualExpanded, limits,
|
|
)
|
|
closeErr := out.Close()
|
|
if copyErr != nil {
|
|
_ = os.Remove(outputPath)
|
|
return nil, copyErr
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(outputPath)
|
|
return nil, closeErr
|
|
}
|
|
extracted[hdr.Name] = struct{}{}
|
|
} else if err := copyArchiveMemberWithLimits(
|
|
io.Discard, tr, hdr.Name, hdr.Size, &actualExpanded, limits,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(extracted) != len(runtimeNames) {
|
|
return nil, fmt.Errorf("archive runtime set extraction was incomplete")
|
|
}
|
|
return runtimeNames, nil
|
|
}
|
|
|
|
func extractZip(
|
|
archivePath, destDir string,
|
|
archiveNames, extractNames []string,
|
|
) ([]string, error) {
|
|
return extractZipWithLimits(
|
|
archivePath,
|
|
destDir,
|
|
archiveNames,
|
|
extractNames,
|
|
defaultArchiveResourceLimits,
|
|
)
|
|
}
|
|
|
|
func extractZipWithLimits(
|
|
archivePath, destDir string,
|
|
archiveNames, extractNames []string,
|
|
limits archiveResourceLimits,
|
|
) ([]string, error) {
|
|
if err := validateArchiveResourceLimits(limits); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireCompressedArchiveWithinLimit(archivePath, limits); err != nil {
|
|
return nil, err
|
|
}
|
|
r, err := zip.OpenReader(archivePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer r.Close()
|
|
if len(r.File) > limits.members {
|
|
return nil, fmt.Errorf(
|
|
"archive exceeds the %d-member safety limit", limits.members,
|
|
)
|
|
}
|
|
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 {
|
|
return nil, err
|
|
}
|
|
if !f.FileInfo().Mode().IsRegular() {
|
|
return nil, fmt.Errorf(
|
|
"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
|
|
}
|
|
names = append(names, name)
|
|
declaredSizes[name] = declaredSize
|
|
}
|
|
if err := validateArchiveMemberNames(names, archiveNames, true); err != nil {
|
|
return nil, err
|
|
}
|
|
runtimeNames := append([]string(nil), extractNames...)
|
|
targetNames := make(map[string]struct{}, len(runtimeNames))
|
|
for _, name := range runtimeNames {
|
|
targetNames[name] = struct{}{}
|
|
}
|
|
extracted := make(map[string]struct{}, len(runtimeNames))
|
|
var actualExpanded int64
|
|
|
|
for index, target := range r.File {
|
|
name := names[index]
|
|
rc, err := target.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var destination io.Writer = io.Discard
|
|
var out *os.File
|
|
outputPath := ""
|
|
if _, wanted := targetNames[name]; wanted {
|
|
outputPath = filepath.Join(destDir, name)
|
|
out, err = os.OpenFile(
|
|
outputPath,
|
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
|
0600,
|
|
)
|
|
if err != nil {
|
|
_ = rc.Close()
|
|
return nil, err
|
|
}
|
|
destination = out
|
|
}
|
|
copyErr := copyArchiveMemberWithLimits(
|
|
destination,
|
|
rc,
|
|
name,
|
|
declaredSizes[name],
|
|
&actualExpanded,
|
|
limits,
|
|
)
|
|
var closeOutErr error
|
|
if out != nil {
|
|
closeOutErr = out.Close()
|
|
}
|
|
closeReadErr := rc.Close()
|
|
if copyErr != nil {
|
|
if outputPath != "" {
|
|
_ = os.Remove(outputPath)
|
|
}
|
|
return nil, copyErr
|
|
}
|
|
if closeOutErr != nil {
|
|
_ = os.Remove(outputPath)
|
|
return nil, closeOutErr
|
|
}
|
|
if closeReadErr != nil {
|
|
if outputPath != "" {
|
|
_ = os.Remove(outputPath)
|
|
}
|
|
return nil, closeReadErr
|
|
}
|
|
if outputPath != "" {
|
|
extracted[name] = struct{}{}
|
|
}
|
|
}
|
|
if len(extracted) != len(runtimeNames) {
|
|
return nil, fmt.Errorf("archive runtime set extraction was incomplete")
|
|
}
|
|
return runtimeNames, nil
|
|
}
|
|
|
|
func validateWindowsZipMember(raw string) (string, error) {
|
|
name := strings.ReplaceAll(raw, "\\", "/")
|
|
segmentsName := strings.TrimSuffix(name, "/")
|
|
if segmentsName == "" || strings.HasPrefix(name, "/") || strings.Contains(name, ":") {
|
|
return "", fmt.Errorf("unsafe zip entry path: %q", raw)
|
|
}
|
|
for _, segment := range strings.Split(segmentsName, "/") {
|
|
if segment == "" || segment == "." || segment == ".." ||
|
|
strings.HasSuffix(segment, ".") || strings.HasSuffix(segment, " ") {
|
|
return "", fmt.Errorf("unsafe zip entry path: %q", raw)
|
|
}
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
func verifyCandidate(path string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), candidateTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, path, "--version")
|
|
cmd.Stdin = nil
|
|
cmd.Stdout = io.Discard
|
|
cmd.Stderr = io.Discard
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return fmt.Errorf("downloaded binary verification timed out: %w", ctx.Err())
|
|
}
|
|
return fmt.Errorf("downloaded binary failed to run: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fileSHA256(path string) ([sha256.Size]byte, error) {
|
|
var digest [sha256.Size]byte
|
|
source, err := os.Open(path)
|
|
if err != nil {
|
|
return digest, err
|
|
}
|
|
defer source.Close()
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(hash, source); err != nil {
|
|
return digest, err
|
|
}
|
|
copy(digest[:], hash.Sum(nil))
|
|
return digest, nil
|
|
}
|
|
|
|
func filesEqualSHA256(left, right string) (bool, error) {
|
|
leftInfo, err := os.Lstat(left)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
rightInfo, err := os.Lstat(right)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !leftInfo.Mode().IsRegular() || !rightInfo.Mode().IsRegular() ||
|
|
!platformRuntimeSetFileLinkCountOne(left, leftInfo) ||
|
|
!platformRuntimeSetFileLinkCountOne(right, rightInfo) ||
|
|
leftInfo.Size() != rightInfo.Size() {
|
|
return false, nil
|
|
}
|
|
leftDigest, err := fileSHA256(left)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
rightDigest, err := fileSHA256(right)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return leftDigest == rightDigest, nil
|
|
}
|
|
|
|
func regularRuntimeFile(path string) bool {
|
|
status, err := os.Lstat(path)
|
|
return err == nil && status.Mode().IsRegular() &&
|
|
platformRuntimeSetFileLinkCountOne(path, status)
|
|
}
|
|
|
|
func runtimeSetNames(directory, binaryName string) ([]string, bool) {
|
|
if !regularRuntimeFile(filepath.Join(directory, binaryName)) {
|
|
return nil, false
|
|
}
|
|
return []string{binaryName}, true
|
|
}
|
|
|
|
func runtimeSetReady(
|
|
directory, binaryName string, verifier func(string) error,
|
|
) bool {
|
|
if _, ok := runtimeSetNames(directory, binaryName); !ok {
|
|
return false
|
|
}
|
|
return verifier == nil || verifier(filepath.Join(directory, binaryName)) == nil
|
|
}
|
|
|
|
func requireSafeRuntimeDirectory(directory string) error {
|
|
status, err := os.Lstat(directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !status.IsDir() || status.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("refusing unsafe package-cache directory: %s", directory)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runtimeSetReadyLocked(
|
|
directory, binaryName string, verifier func(string) error,
|
|
) (ready bool, result error) {
|
|
if err := os.MkdirAll(directory, 0755); err != nil {
|
|
return false, err
|
|
}
|
|
if err := requireSafeRuntimeDirectory(directory); err != nil {
|
|
return false, err
|
|
}
|
|
lock, err := acquireRuntimeSetLock(directory)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() {
|
|
attachRuntimeLockReleaseError(&result, releaseRuntimeSetLock(lock))
|
|
}()
|
|
if err := refreshRuntimeSetLock(lock); err != nil {
|
|
return false, err
|
|
}
|
|
if err := reconcileRuntimeBackups(
|
|
directory, binaryName, verifier, lock,
|
|
); err != nil {
|
|
return false, err
|
|
}
|
|
return runtimeSetReady(directory, binaryName, verifier), nil
|
|
}
|
|
|
|
func copyRuntimeStage(
|
|
sourcePath, destinationDirectory string, executable bool,
|
|
maintainLease func() error,
|
|
) (string, error) {
|
|
input, err := os.Open(sourcePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer input.Close()
|
|
pattern := ".cbm-runtime-stage-*"
|
|
if executable && runtime.GOOS == "windows" {
|
|
pattern += ".exe"
|
|
}
|
|
output, err := os.CreateTemp(destinationDirectory, pattern)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
stagedPath := output.Name()
|
|
mode := os.FileMode(0644)
|
|
if executable {
|
|
mode = 0755
|
|
}
|
|
buffer := make([]byte, 1024*1024)
|
|
lastRefresh := time.Now()
|
|
var copyErr error
|
|
for {
|
|
count, readErr := input.Read(buffer)
|
|
if count > 0 {
|
|
written := 0
|
|
for written < count {
|
|
amount, writeErr := output.Write(buffer[written:count])
|
|
if writeErr != nil {
|
|
copyErr = writeErr
|
|
break
|
|
}
|
|
written += amount
|
|
}
|
|
}
|
|
if copyErr != nil || readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
copyErr = readErr
|
|
break
|
|
}
|
|
if maintainLease != nil && time.Since(lastRefresh) >= runtimeSetLockHeartbeat {
|
|
if err := maintainLease(); err != nil {
|
|
copyErr = err
|
|
break
|
|
}
|
|
lastRefresh = time.Now()
|
|
}
|
|
}
|
|
chmodErr := output.Chmod(mode)
|
|
syncErr := output.Sync()
|
|
closeErr := output.Close()
|
|
for _, candidate := range []error{copyErr, chmodErr, syncErr, closeErr} {
|
|
if candidate != nil {
|
|
_ = os.Remove(stagedPath)
|
|
return "", candidate
|
|
}
|
|
}
|
|
return stagedPath, nil
|
|
}
|
|
|
|
// A grouped backup is a small process-crash journal. runtimeBackupRetired
|
|
// separates a partial retirement from partial publication;
|
|
// runtimeBackupCleanupOnly makes deletion retryable after the destination has
|
|
// been accepted or restored.
|
|
func runtimeBackupDirectoryName(name string) bool {
|
|
if !strings.HasPrefix(name, runtimeBackupPrefix) {
|
|
return false
|
|
}
|
|
token := strings.TrimPrefix(name, runtimeBackupPrefix)
|
|
decoded, err := hex.DecodeString(token)
|
|
return err == nil && token == strings.ToLower(token) &&
|
|
len(decoded) == runtimeSetLockTokenSize
|
|
}
|
|
|
|
func runtimeBackupTargetName(name, binaryName string) bool {
|
|
return name == binaryName
|
|
}
|
|
|
|
func createRuntimeBackupDirectory(directory string) (string, error) {
|
|
for attempt := 0; attempt < 128; attempt++ {
|
|
token, err := runtimeSetLockToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
backupDirectory := filepath.Join(directory, runtimeBackupPrefix+token)
|
|
if err := os.Mkdir(backupDirectory, 0700); err == nil {
|
|
return backupDirectory, nil
|
|
} else if !os.IsExist(err) {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", fmt.Errorf("could not reserve a package-cache backup transaction")
|
|
}
|
|
|
|
func createRuntimeBackupMarker(backupDirectory, name string) error {
|
|
marker, err := os.OpenFile(
|
|
filepath.Join(backupDirectory, name),
|
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
|
0600,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
syncErr := marker.Sync()
|
|
closeErr := marker.Close()
|
|
if syncErr != nil {
|
|
return syncErr
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
func inspectRuntimeBackupDirectory(
|
|
directory, entryName, binaryName string,
|
|
) (*runtimeBackupTransaction, error) {
|
|
backupDirectory := filepath.Join(directory, entryName)
|
|
directoryStatus, err := os.Lstat(backupDirectory)
|
|
if err != nil || !directoryStatus.IsDir() ||
|
|
directoryStatus.Mode()&os.ModeSymlink != 0 {
|
|
return nil, fmt.Errorf(
|
|
"refusing unsafe package-cache backup: %s", backupDirectory,
|
|
)
|
|
}
|
|
backup := &runtimeBackupTransaction{
|
|
path: backupDirectory, files: make(map[string]runtimeBackupFile),
|
|
}
|
|
entries, err := os.ReadDir(backupDirectory)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
candidate := filepath.Join(backupDirectory, name)
|
|
status, err := os.Lstat(candidate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == runtimeBackupRetired || name == runtimeBackupCleanupOnly {
|
|
if !status.Mode().IsRegular() ||
|
|
!platformRuntimeSetFileLinkCountOne(candidate, status) ||
|
|
status.Size() != 0 {
|
|
return nil, fmt.Errorf(
|
|
"refusing unsafe package-cache backup marker: %s", candidate,
|
|
)
|
|
}
|
|
if name == runtimeBackupRetired {
|
|
backup.retired = true
|
|
} else {
|
|
backup.cleanupOnly = true
|
|
}
|
|
continue
|
|
}
|
|
if !runtimeBackupTargetName(name, binaryName) ||
|
|
!status.Mode().IsRegular() ||
|
|
!platformRuntimeSetFileLinkCountOne(candidate, status) {
|
|
return nil, fmt.Errorf(
|
|
"refusing unsafe package-cache backup member: %s", candidate,
|
|
)
|
|
}
|
|
digest, err := fileSHA256(candidate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
backup.files[name] = runtimeBackupFile{path: candidate, digest: digest}
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
func listRuntimeBackupDirectories(
|
|
directory, binaryName string,
|
|
) ([]*runtimeBackupTransaction, error) {
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var names []string
|
|
for _, entry := range entries {
|
|
if runtimeBackupDirectoryName(entry.Name()) {
|
|
names = append(names, entry.Name())
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
backups := make([]*runtimeBackupTransaction, 0, len(names))
|
|
for _, name := range names {
|
|
backup, err := inspectRuntimeBackupDirectory(directory, name, binaryName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
backups = append(backups, backup)
|
|
}
|
|
return backups, nil
|
|
}
|
|
|
|
func currentRuntimeTargets(
|
|
directory, binaryName string,
|
|
) (map[string]string, error) {
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets := make(map[string]string)
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !runtimeBackupTargetName(name, binaryName) {
|
|
continue
|
|
}
|
|
target := filepath.Join(directory, name)
|
|
status, err := os.Lstat(target)
|
|
if err != nil || !status.Mode().IsRegular() ||
|
|
!platformRuntimeSetFileLinkCountOne(target, status) {
|
|
return nil, fmt.Errorf("refusing unsafe package-cache target: %s", target)
|
|
}
|
|
targets[name] = target
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func cleanupRuntimeBackup(
|
|
backup *runtimeBackupTransaction, lock *runtimeSetLock,
|
|
) error {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if !backup.cleanupOnly {
|
|
if err := createRuntimeBackupMarker(
|
|
backup.path, runtimeBackupCleanupOnly,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
backup.cleanupOnly = true
|
|
}
|
|
names := make([]string, 0, len(backup.files))
|
|
for name := range backup.files {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
member := backup.files[name]
|
|
if !pathMatchesSHA256(member.path, member.digest) {
|
|
return fmt.Errorf(
|
|
"package-cache backup member changed during cleanup: %s", name,
|
|
)
|
|
}
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(member.path); err != nil {
|
|
return err
|
|
}
|
|
if runtimeBackupCrashObserver != nil {
|
|
if err := runtimeBackupCrashObserver(
|
|
runtimeBackupCleanupRemovedEvent, member.path,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if backup.retired {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(filepath.Join(backup.path, runtimeBackupRetired)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(filepath.Join(backup.path, runtimeBackupCleanupOnly)); err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(backup.path)
|
|
}
|
|
|
|
func copyRuntimeBackupFile(
|
|
member runtimeBackupFile, target string, executable bool, lock *runtimeSetLock,
|
|
) error {
|
|
staged, err := copyRuntimeStage(
|
|
member.path,
|
|
filepath.Dir(target),
|
|
executable,
|
|
func() error { return refreshRuntimeSetLock(lock) },
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(staged)
|
|
stagedDigest, err := fileSHA256(staged)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if stagedDigest != member.digest {
|
|
return fmt.Errorf("package-cache backup changed while it was being restored")
|
|
}
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(staged, target)
|
|
}
|
|
|
|
func reconcileRuntimeBackups(
|
|
directory, binaryName string,
|
|
verifier func(string) error,
|
|
lock *runtimeSetLock,
|
|
) error {
|
|
if err := refreshRuntimeSetLock(lock); err != nil {
|
|
return err
|
|
}
|
|
backups, err := listRuntimeBackupDirectories(directory, binaryName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remaining := make([]*runtimeBackupTransaction, 0, len(backups))
|
|
for _, backup := range backups {
|
|
if backup.cleanupOnly {
|
|
if err := cleanupRuntimeBackup(backup, lock); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
remaining = append(remaining, backup)
|
|
}
|
|
}
|
|
backups = remaining
|
|
if len(backups) == 0 {
|
|
return nil
|
|
}
|
|
if runtimeSetReady(directory, binaryName, verifier) {
|
|
for _, backup := range backups {
|
|
if err := cleanupRuntimeBackup(backup, lock); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if len(backups) == 0 {
|
|
return nil
|
|
}
|
|
remaining = remaining[:0]
|
|
for _, backup := range backups {
|
|
if !backup.retired && len(backup.files) == 0 {
|
|
if err := cleanupRuntimeBackup(backup, lock); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
remaining = append(remaining, backup)
|
|
}
|
|
}
|
|
backups = remaining
|
|
if len(backups) == 0 {
|
|
return nil
|
|
}
|
|
if len(backups) != 1 {
|
|
return fmt.Errorf(
|
|
"multiple package-cache backup transactions require manual recovery",
|
|
)
|
|
}
|
|
backup := backups[0]
|
|
targets, err := currentRuntimeTargets(directory, binaryName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !backup.retired {
|
|
for name, member := range backup.files {
|
|
if target, exists := targets[name]; exists &&
|
|
!pathMatchesSHA256(target, member.digest) {
|
|
return fmt.Errorf(
|
|
"package-cache backup conflicts with current target: %s", name,
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
if target, exists := targets[binaryName]; exists {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(target); err != nil {
|
|
return err
|
|
}
|
|
delete(targets, binaryName)
|
|
}
|
|
names := make([]string, 0, len(targets))
|
|
for name := range targets {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(targets[name]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
restoreNames := make([]string, 0, len(backup.files))
|
|
for name := range backup.files {
|
|
if name != binaryName {
|
|
restoreNames = append(restoreNames, name)
|
|
}
|
|
}
|
|
sort.Strings(restoreNames)
|
|
if _, exists := backup.files[binaryName]; exists {
|
|
restoreNames = append(restoreNames, binaryName)
|
|
}
|
|
for _, name := range restoreNames {
|
|
member := backup.files[name]
|
|
target := filepath.Join(directory, name)
|
|
if !pathMatchesSHA256(target, member.digest) {
|
|
if err := copyRuntimeBackupFile(
|
|
member, target, name == binaryName, lock,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for name, member := range backup.files {
|
|
if !pathMatchesSHA256(filepath.Join(directory, name), member.digest) {
|
|
return fmt.Errorf("package-cache backup restoration failed: %s", name)
|
|
}
|
|
}
|
|
if err := cleanupRuntimeBackup(backup, lock); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathMatchesSHA256(path string, expected [sha256.Size]byte) bool {
|
|
if !regularRuntimeFile(path) {
|
|
return false
|
|
}
|
|
actual, err := fileSHA256(path)
|
|
return err == nil && actual == expected
|
|
}
|
|
|
|
func runtimeSetLockToken() (string, error) {
|
|
raw := make([]byte, runtimeSetLockTokenSize)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(raw), nil
|
|
}
|
|
|
|
func runtimeSetLockOwner(lockPath string) (runtimeSetLockOwnerRecord, bool) {
|
|
var empty runtimeSetLockOwnerRecord
|
|
status, err := os.Lstat(lockPath)
|
|
if err != nil || status.Mode()&os.ModeSymlink != 0 ||
|
|
(!status.Mode().IsRegular() && !status.IsDir()) {
|
|
return empty, false
|
|
}
|
|
ownerPath := lockPath
|
|
if status.IsDir() {
|
|
ownerPath = filepath.Join(lockPath, runtimeSetLockOwnerFile)
|
|
}
|
|
ownerStatus, err := os.Lstat(ownerPath)
|
|
if err != nil || !ownerStatus.Mode().IsRegular() {
|
|
return empty, false
|
|
}
|
|
ownerFile, err := platformOpenRuntimeSetLockOwner(ownerPath)
|
|
if err != nil {
|
|
return empty, false
|
|
}
|
|
if runtimeSetLockOwnerReadObserver != nil {
|
|
runtimeSetLockOwnerReadObserver()
|
|
}
|
|
data, readErr := io.ReadAll(ownerFile)
|
|
closeErr := ownerFile.Close()
|
|
if readErr != nil || closeErr != nil {
|
|
return empty, false
|
|
}
|
|
var record runtimeSetLockOwnerRecord
|
|
if err := json.Unmarshal(data, &record); err != nil {
|
|
return empty, false
|
|
}
|
|
decoded, decodeErr := hex.DecodeString(record.Token)
|
|
if record.PID <= 0 || record.Token != strings.ToLower(record.Token) ||
|
|
decodeErr != nil || len(decoded) != runtimeSetLockTokenSize {
|
|
return empty, false
|
|
}
|
|
return record, true
|
|
}
|
|
|
|
func runtimeSetSameLockObject(left, right os.FileInfo) bool {
|
|
return left != nil && right != nil && os.SameFile(left, right) &&
|
|
left.Mode().Type() == right.Mode().Type()
|
|
}
|
|
|
|
// captureRuntimeSetLockObject resolves the lock's file identity while its
|
|
// canonical pathname still exists. On Windows, os.FileInfo identity can be
|
|
// resolved lazily from its pathname; carrying an untouched Lstat result across
|
|
// a rename therefore loses the object we meant to revalidate.
|
|
func captureRuntimeSetLockObject(
|
|
lockPath string, observedStatus os.FileInfo,
|
|
) (os.FileInfo, error) {
|
|
object, err := os.Open(lockPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if runtimeSetLockCaptureObserver != nil {
|
|
runtimeSetLockCaptureObserver()
|
|
}
|
|
descriptorStatus, descriptorErr := object.Stat()
|
|
canonicalStatus, canonicalErr := os.Lstat(lockPath)
|
|
identityOK := descriptorErr == nil && canonicalErr == nil &&
|
|
runtimeSetSameLockObject(observedStatus, descriptorStatus) &&
|
|
runtimeSetSameLockObject(descriptorStatus, canonicalStatus)
|
|
closeErr := object.Close()
|
|
if !identityOK {
|
|
return nil, fmt.Errorf(
|
|
"package-cache runtime-set publication lock ownership changed",
|
|
)
|
|
}
|
|
if closeErr != nil {
|
|
return nil, closeErr
|
|
}
|
|
return descriptorStatus, nil
|
|
}
|
|
|
|
func writeRuntimeSetLockRecord(owner *os.File, token string) error {
|
|
if err := owner.Truncate(0); err != nil {
|
|
return err
|
|
}
|
|
if _, err := owner.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
if err := json.NewEncoder(owner).Encode(runtimeSetLockOwnerRecord{
|
|
PID: os.Getpid(),
|
|
Token: token,
|
|
LeaseExpires: time.Now().Add(runtimeSetLockLease).UnixMilli(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return owner.Sync()
|
|
}
|
|
|
|
// writeRuntimeSetLockOwner creates the legacy directory-owner shape used by
|
|
// migration tests and previous wrapper releases.
|
|
func writeRuntimeSetLockOwner(lockPath, token string) error {
|
|
owner, err := os.OpenFile(
|
|
filepath.Join(lockPath, runtimeSetLockOwnerFile),
|
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
|
0600,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeErr := json.NewEncoder(owner).Encode(runtimeSetLockOwnerRecord{
|
|
PID: os.Getpid(), Token: token,
|
|
})
|
|
syncErr := owner.Sync()
|
|
closeErr := owner.Close()
|
|
for _, candidate := range []error{writeErr, syncErr, closeErr} {
|
|
if candidate != nil {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runtimeSetOwnerReclaimable(
|
|
status os.FileInfo, owner runtimeSetLockOwnerRecord, ownerOK bool,
|
|
) bool {
|
|
if ownerOK {
|
|
// Lease expiry and age are not evidence that a syntactically valid
|
|
// owner is dead. A paused live publisher can resume between lock-owner
|
|
// assertions, so reclaim only when the platform proves its PID is gone.
|
|
return !runtimeSetProcessAlive(owner.PID)
|
|
}
|
|
return time.Since(status.ModTime()) >= runtimeSetOwnerlessStale
|
|
}
|
|
|
|
func restoreDisplacedRuntimeSetLock(reclaimed, lockPath string, status os.FileInfo) {
|
|
if status == nil || !status.Mode().IsRegular() {
|
|
return
|
|
}
|
|
if err := os.Link(reclaimed, lockPath); err == nil {
|
|
_ = os.Remove(reclaimed)
|
|
}
|
|
}
|
|
|
|
func runtimeSetTryReclaimLock(lockPath, contenderToken string) bool {
|
|
observedStatus, err := os.Lstat(lockPath)
|
|
if os.IsNotExist(err) {
|
|
return true
|
|
}
|
|
if err != nil || observedStatus.Mode()&os.ModeSymlink != 0 ||
|
|
(!observedStatus.Mode().IsRegular() && !observedStatus.IsDir()) {
|
|
return false
|
|
}
|
|
owner, ownerOK := runtimeSetLockOwner(lockPath)
|
|
if !runtimeSetOwnerReclaimable(observedStatus, owner, ownerOK) {
|
|
return false
|
|
}
|
|
// Pin identity only after the observed owner is reclaimable. On Windows,
|
|
// opening a proven-live lock here can deny the owner's release rename.
|
|
observedStatus, err = captureRuntimeSetLockObject(lockPath, observedStatus)
|
|
if err != nil {
|
|
return os.IsNotExist(err)
|
|
}
|
|
reclaimed := lockPath + ".reclaimed-" + contenderToken
|
|
if err := os.Rename(lockPath, reclaimed); err != nil {
|
|
return os.IsNotExist(err)
|
|
}
|
|
movedStatus, movedErr := os.Lstat(reclaimed)
|
|
if movedErr != nil || !runtimeSetSameLockObject(observedStatus, movedStatus) {
|
|
restoreDisplacedRuntimeSetLock(reclaimed, lockPath, movedStatus)
|
|
return false
|
|
}
|
|
movedOwner, movedOwnerOK := runtimeSetLockOwner(reclaimed)
|
|
if !runtimeSetOwnerReclaimable(movedStatus, movedOwner, movedOwnerOK) {
|
|
restoreDisplacedRuntimeSetLock(reclaimed, lockPath, movedStatus)
|
|
return false
|
|
}
|
|
if movedStatus.IsDir() {
|
|
_ = os.RemoveAll(reclaimed)
|
|
} else {
|
|
_ = os.Remove(reclaimed)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func assertRuntimeSetLockOwner(lock *runtimeSetLock) error {
|
|
if lock == nil || lock.file == nil {
|
|
return fmt.Errorf("missing package-cache runtime-set publication lock")
|
|
}
|
|
descriptorStatus, descriptorErr := lock.file.Stat()
|
|
canonicalStatus, canonicalErr := os.Lstat(lock.path)
|
|
owner, ownerOK := runtimeSetLockOwner(lock.path)
|
|
if descriptorErr != nil || canonicalErr != nil ||
|
|
!runtimeSetSameLockObject(descriptorStatus, canonicalStatus) ||
|
|
!ownerOK || owner.PID != os.Getpid() || owner.Token != lock.token {
|
|
return fmt.Errorf(
|
|
"package-cache runtime-set publication lock ownership changed",
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func refreshRuntimeSetLock(lock *runtimeSetLock) error {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := writeRuntimeSetLockRecord(lock.file, lock.token); err != nil {
|
|
return err
|
|
}
|
|
return assertRuntimeSetLockOwner(lock)
|
|
}
|
|
|
|
func acquireRuntimeSetLock(destinationDirectory string) (*runtimeSetLock, error) {
|
|
token, err := runtimeSetLockToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lockPath := filepath.Join(destinationDirectory, runtimeSetLockName)
|
|
claimPath := lockPath + ".claim-" + token
|
|
deadline := time.Now().Add(runtimeSetLockWait)
|
|
for {
|
|
owner, claimErr := os.OpenFile(
|
|
claimPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600,
|
|
)
|
|
if claimErr != nil {
|
|
return nil, claimErr
|
|
}
|
|
if err := writeRuntimeSetLockRecord(owner, token); err != nil {
|
|
_ = owner.Close()
|
|
_ = os.Remove(claimPath)
|
|
return nil, err
|
|
}
|
|
if runtimeSetLockClaimObserver != nil {
|
|
if err := runtimeSetLockClaimObserver(); err != nil {
|
|
_ = owner.Close()
|
|
_ = os.Remove(claimPath)
|
|
return nil, err
|
|
}
|
|
}
|
|
descriptorStatus, descriptorErr := owner.Stat()
|
|
claimStatus, claimStatusErr := os.Lstat(claimPath)
|
|
if descriptorErr != nil || claimStatusErr != nil ||
|
|
!runtimeSetSameLockObject(descriptorStatus, claimStatus) {
|
|
_ = owner.Close()
|
|
_ = os.Remove(claimPath)
|
|
return nil, fmt.Errorf(
|
|
"package-cache runtime-set publication lock ownership changed",
|
|
)
|
|
}
|
|
if err := owner.Close(); err != nil {
|
|
_ = os.Remove(claimPath)
|
|
return nil, err
|
|
}
|
|
linkErr := os.Link(claimPath, lockPath)
|
|
if linkErr == nil {
|
|
if err := os.Remove(claimPath); err != nil {
|
|
return nil, err
|
|
}
|
|
owner, openErr := os.OpenFile(lockPath, os.O_RDWR, 0)
|
|
if openErr != nil {
|
|
return nil, openErr
|
|
}
|
|
reopenedStatus, reopenedErr := owner.Stat()
|
|
canonicalStatus, canonicalErr := os.Lstat(lockPath)
|
|
lockOwner, ownerOK := runtimeSetLockOwner(lockPath)
|
|
if reopenedErr != nil || canonicalErr != nil ||
|
|
!runtimeSetSameLockObject(descriptorStatus, reopenedStatus) ||
|
|
!runtimeSetSameLockObject(reopenedStatus, canonicalStatus) ||
|
|
!ownerOK || lockOwner.PID != os.Getpid() ||
|
|
lockOwner.Token != token {
|
|
_ = owner.Close()
|
|
return nil, fmt.Errorf(
|
|
"package-cache runtime-set publication lock ownership changed",
|
|
)
|
|
}
|
|
return &runtimeSetLock{path: lockPath, token: token, file: owner}, nil
|
|
}
|
|
_ = os.Remove(claimPath)
|
|
if !os.IsExist(linkErr) {
|
|
return nil, linkErr
|
|
}
|
|
if runtimeSetTryReclaimLock(lockPath, token) {
|
|
continue
|
|
}
|
|
if runtimeSetLockWaitObserver != nil {
|
|
runtimeSetLockWaitObserver()
|
|
}
|
|
if !time.Now().Before(deadline) {
|
|
return nil, fmt.Errorf(
|
|
"timed out waiting for package-cache runtime-set publication lock",
|
|
)
|
|
}
|
|
time.Sleep(runtimeSetLockPoll)
|
|
}
|
|
}
|
|
|
|
func releaseRuntimeSetLock(lock *runtimeSetLock) error {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
if lock != nil && lock.file != nil {
|
|
_ = lock.file.Close()
|
|
lock.file = nil
|
|
}
|
|
return err
|
|
}
|
|
descriptorStatus, descriptorErr := lock.file.Stat()
|
|
canonicalStatus, canonicalErr := os.Lstat(lock.path)
|
|
owner, ownerOK := runtimeSetLockOwner(lock.path)
|
|
if descriptorErr != nil || canonicalErr != nil ||
|
|
!runtimeSetSameLockObject(descriptorStatus, canonicalStatus) ||
|
|
!ownerOK || owner.PID != os.Getpid() || owner.Token != lock.token {
|
|
_ = lock.file.Close()
|
|
lock.file = nil
|
|
return fmt.Errorf(
|
|
"package-cache runtime-set publication lock ownership changed",
|
|
)
|
|
}
|
|
if err := lock.file.Close(); err != nil {
|
|
lock.file = nil
|
|
return err
|
|
}
|
|
lock.file = nil
|
|
released := lock.path + ".released-" + lock.token
|
|
if err := os.Rename(lock.path, released); err != nil {
|
|
return err
|
|
}
|
|
releasedStatus, releasedErr := os.Lstat(released)
|
|
owner, ownerOK = runtimeSetLockOwner(released)
|
|
if releasedErr != nil ||
|
|
!runtimeSetSameLockObject(descriptorStatus, releasedStatus) ||
|
|
!ownerOK || owner.PID != os.Getpid() || owner.Token != lock.token {
|
|
restoreDisplacedRuntimeSetLock(released, lock.path, releasedStatus)
|
|
return fmt.Errorf(
|
|
"package-cache runtime-set publication lock ownership changed",
|
|
)
|
|
}
|
|
return os.Remove(released)
|
|
}
|
|
|
|
func attachRuntimeLockReleaseError(result *error, releaseErr error) {
|
|
if releaseErr == nil {
|
|
return
|
|
}
|
|
if *result == nil {
|
|
*result = releaseErr
|
|
return
|
|
}
|
|
*result = fmt.Errorf("%v; runtime lock release failed: %w", *result, releaseErr)
|
|
}
|
|
|
|
func publishRuntimeSetWithRecoveryAndRenamer(
|
|
sourceDirectory, destinationDirectory, binaryName string,
|
|
verifier func(string) error,
|
|
renameFile func(string, string) error,
|
|
) (result error) {
|
|
sourceNames, ok := runtimeSetNames(sourceDirectory, binaryName)
|
|
if !ok {
|
|
return fmt.Errorf("source release does not contain a complete runtime set")
|
|
}
|
|
if verifier != nil {
|
|
if err := verifier(filepath.Join(sourceDirectory, binaryName)); err != nil {
|
|
return fmt.Errorf("source runtime binary failed verification: %w", err)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(destinationDirectory, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := requireSafeRuntimeDirectory(destinationDirectory); err != nil {
|
|
return err
|
|
}
|
|
lock, err := acquireRuntimeSetLock(destinationDirectory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
attachRuntimeLockReleaseError(&result, releaseRuntimeSetLock(lock))
|
|
}()
|
|
if err := refreshRuntimeSetLock(lock); err != nil {
|
|
return err
|
|
}
|
|
if err := reconcileRuntimeBackups(
|
|
destinationDirectory, binaryName, verifier, lock,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
// A contender may have committed while this process waited. Its complete
|
|
// runtime set wins and is never retired or mixed with this contender.
|
|
if runtimeSetReady(destinationDirectory, binaryName, verifier) {
|
|
return nil
|
|
}
|
|
|
|
staged := make(map[string]string, len(sourceNames))
|
|
backupDirectory := ""
|
|
defer func() {
|
|
for _, stagedPath := range staged {
|
|
_ = os.Remove(stagedPath)
|
|
}
|
|
}()
|
|
|
|
operationErr := func() error {
|
|
for _, name := range sourceNames {
|
|
stagedPath, err := copyRuntimeStage(
|
|
filepath.Join(sourceDirectory, name),
|
|
destinationDirectory,
|
|
name == binaryName,
|
|
func() error { return refreshRuntimeSetLock(lock) },
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
staged[name] = stagedPath
|
|
}
|
|
if err := refreshRuntimeSetLock(lock); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
backupDirectory, err = createRuntimeBackupDirectory(destinationDirectory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
retireNames := []string{binaryName}
|
|
retired := make(map[string]struct{}, len(retireNames))
|
|
// Retire the executable first so a partial publication is never ready.
|
|
for _, name := range retireNames {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if _, duplicate := retired[name]; duplicate {
|
|
continue
|
|
}
|
|
retired[name] = struct{}{}
|
|
target := filepath.Join(destinationDirectory, name)
|
|
status, err := os.Lstat(target)
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !status.Mode().IsRegular() ||
|
|
!platformRuntimeSetFileLinkCountOne(target, status) {
|
|
return fmt.Errorf("refusing unsafe package-cache target: %s", target)
|
|
}
|
|
backup := filepath.Join(backupDirectory, name)
|
|
if err := renameFile(target, backup); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
if runtimeBackupCrashObserver != nil {
|
|
if err := runtimeBackupCrashObserver(
|
|
runtimeBackupBeforeMarkerEvent, backupDirectory,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := createRuntimeBackupMarker(
|
|
backupDirectory, runtimeBackupRetired,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
// runtimeSetNames orders every sidecar before the binary readiness signal.
|
|
for _, name := range sourceNames {
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(destinationDirectory, name)
|
|
if err := renameFile(staged[name], target); err != nil {
|
|
identical, compareErr := filesEqualSHA256(staged[name], target)
|
|
if compareErr != nil || !identical {
|
|
return err
|
|
}
|
|
_ = os.Remove(staged[name])
|
|
}
|
|
delete(staged, name)
|
|
}
|
|
if !runtimeSetReady(destinationDirectory, binaryName, verifier) {
|
|
return fmt.Errorf("published package-cache runtime set failed verification")
|
|
}
|
|
if err := assertRuntimeSetLockOwner(lock); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
if operationErr != nil {
|
|
if lockErr := assertRuntimeSetLockOwner(lock); lockErr != nil {
|
|
return fmt.Errorf("publication failed and lock ownership changed: %v: %w", operationErr, lockErr)
|
|
}
|
|
}
|
|
if operationErr == nil {
|
|
backup, err := inspectRuntimeBackupDirectory(
|
|
destinationDirectory, filepath.Base(backupDirectory), binaryName,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cleanupRuntimeBackup(backup, lock); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
preserveWinner := runtimeSetReady(
|
|
destinationDirectory, binaryName, verifier,
|
|
)
|
|
if backupDirectory != "" {
|
|
if recoveryErr := reconcileRuntimeBackups(
|
|
destinationDirectory, binaryName, verifier, lock,
|
|
); recoveryErr != nil {
|
|
return fmt.Errorf(
|
|
"%v; package-cache recovery failed: %w", operationErr, recoveryErr,
|
|
)
|
|
}
|
|
}
|
|
if preserveWinner {
|
|
return nil
|
|
}
|
|
return operationErr
|
|
}
|
|
|
|
func publishRuntimeSetWithRecovery(
|
|
sourceDirectory, destinationDirectory, binaryName string,
|
|
verifier func(string) error,
|
|
) error {
|
|
return publishRuntimeSetWithRecoveryAndRenamer(
|
|
sourceDirectory,
|
|
destinationDirectory,
|
|
binaryName,
|
|
verifier,
|
|
os.Rename,
|
|
)
|
|
}
|
|
|
|
func execBinary(executable string, args []string) error {
|
|
if runtime.GOOS == "windows" {
|
|
cmd := exec.Command(executable, args...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
return syscall.Exec(executable, append([]string{executable}, args...), os.Environ())
|
|
}
|
|
|
|
func createMutationRuntimeSnapshot(
|
|
executable string,
|
|
verifier func(string) error,
|
|
maintainLease func() error,
|
|
) (snapshotDirectory, snapshotExecutable string, result error) {
|
|
sourceDirectory := filepath.Dir(executable)
|
|
binaryName := filepath.Base(executable)
|
|
names, ok := runtimeSetNames(sourceDirectory, binaryName)
|
|
if !ok {
|
|
return "", "", fmt.Errorf(
|
|
"cached runtime assets changed before mutation snapshot",
|
|
)
|
|
}
|
|
|
|
snapshotDirectory, err := platformCreatePrivateMutationRuntimeDirectory()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
createdDirectory := snapshotDirectory
|
|
defer func() {
|
|
if result != nil {
|
|
_ = os.RemoveAll(createdDirectory)
|
|
}
|
|
}()
|
|
if err := requireSafeRuntimeDirectory(snapshotDirectory); err != nil {
|
|
return "", "", fmt.Errorf(
|
|
"owner-private mutation runtime snapshot is unsafe: %w", err,
|
|
)
|
|
}
|
|
|
|
for _, name := range names {
|
|
sourcePath := filepath.Join(sourceDirectory, name)
|
|
expectedDigest, err := fileSHA256(sourcePath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
stagedPath, err := copyRuntimeStage(
|
|
sourcePath,
|
|
snapshotDirectory,
|
|
name == binaryName,
|
|
maintainLease,
|
|
)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
stagedDigest, digestErr := fileSHA256(stagedPath)
|
|
if digestErr != nil || stagedDigest != expectedDigest ||
|
|
!pathMatchesSHA256(sourcePath, expectedDigest) {
|
|
_ = os.Remove(stagedPath)
|
|
return "", "", fmt.Errorf(
|
|
"cached runtime asset changed while mutation snapshot was copied: %s",
|
|
name,
|
|
)
|
|
}
|
|
if err := os.Rename(
|
|
stagedPath, filepath.Join(snapshotDirectory, name),
|
|
); err != nil {
|
|
_ = os.Remove(stagedPath)
|
|
return "", "", err
|
|
}
|
|
}
|
|
|
|
snapshotExecutable = filepath.Join(snapshotDirectory, binaryName)
|
|
if !runtimeSetReady(
|
|
snapshotDirectory, binaryName, verifier,
|
|
) {
|
|
return "", "", fmt.Errorf(
|
|
"private mutation runtime snapshot failed verification",
|
|
)
|
|
}
|
|
return snapshotDirectory, snapshotExecutable, nil
|
|
}
|
|
|
|
func runMutationBinary(executable string, args []string) error {
|
|
cmd := exec.Command(executable, args...)
|
|
cmd.Env = mutationSnapshotEnvironment()
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func execBinaryWithRuntimeLockAndRunner(
|
|
executable string,
|
|
args []string,
|
|
verifier func(string) error,
|
|
runner func(string, []string) error,
|
|
) (result error) {
|
|
if runner == nil {
|
|
return fmt.Errorf("missing mutation runtime runner")
|
|
}
|
|
directory := filepath.Dir(executable)
|
|
if target, ok := mutationTargetDirectory(args); ok &&
|
|
pathsReferToSameDirectory(target, directory) {
|
|
return fmt.Errorf(
|
|
"refusing mutation target that overlaps the shared package cache: %s",
|
|
target,
|
|
)
|
|
}
|
|
if err := requireSafeRuntimeDirectory(directory); err != nil {
|
|
return err
|
|
}
|
|
lock, err := acquireRuntimeSetLock(directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lockHeld := true
|
|
defer func() {
|
|
if lockHeld {
|
|
attachRuntimeLockReleaseError(&result, releaseRuntimeSetLock(lock))
|
|
}
|
|
}()
|
|
if err := refreshRuntimeSetLock(lock); err != nil {
|
|
return err
|
|
}
|
|
if !runtimeSetReady(
|
|
directory, filepath.Base(executable), verifier,
|
|
) {
|
|
return fmt.Errorf("cached runtime assets changed before mutation launch")
|
|
}
|
|
snapshotDirectory, snapshotExecutable, err := createMutationRuntimeSnapshot(
|
|
executable,
|
|
verifier,
|
|
func() error { return refreshRuntimeSetLock(lock) },
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
// A hard-killed wrapper can bypass this cleanup while its native child
|
|
// still consumes adjacent snapshot assets. Do not scavenge such a
|
|
// directory by PID or age, and never replace the native command result
|
|
// merely because best-effort cleanup leaked an isolated private copy.
|
|
if cleanupErr := runtimeMutationSnapshotCleanup(snapshotDirectory); cleanupErr != nil {
|
|
fmt.Fprintf(
|
|
os.Stderr,
|
|
"codebase-memory-mcp: warning: private mutation snapshot cleanup failed: %v\n",
|
|
cleanupErr,
|
|
)
|
|
}
|
|
}()
|
|
releaseErr := releaseRuntimeSetLock(lock)
|
|
lockHeld = false
|
|
if releaseErr != nil {
|
|
return releaseErr
|
|
}
|
|
return runner(snapshotExecutable, args)
|
|
}
|
|
|
|
func execBinaryWithRuntimeLock(executable string, args []string) error {
|
|
return execBinaryWithRuntimeLockAndRunner(
|
|
executable,
|
|
args,
|
|
verifyCandidate,
|
|
runMutationBinary,
|
|
)
|
|
}
|