mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-05 10:19:15 +00:00
Squash merge PR #97 after resolving changelog conflict with current dev. PR CI run 26703171763 passed release-versioning, go, and web.
407 lines
12 KiB
Go
407 lines
12 KiB
Go
package skills
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Sentinel errors for the update executor.
|
||
var (
|
||
ErrUpdateChecksumMismatch = errors.New("github.update: asset checksum mismatch")
|
||
ErrUpdateSwapFailed = errors.New("github.update: atomic swap failed (previous version restored)")
|
||
ErrUpdateManifestDesync = errors.New("github.update: binary swapped but manifest save failed (manual recovery required)")
|
||
)
|
||
|
||
// GitHubUpdateExecutor implements UpdateExecutor for "github" source.
|
||
// Shares the installer's config and client; executor itself is lock-free
|
||
// (caller uses PackageLocker). Red-team fixes applied:
|
||
// - C1: two-phase swap — all olds → .bak BEFORE any new → dest.
|
||
// - C3: re-verifies asset via meta SHA256 when present; refuses staged
|
||
// URL whose host is not in allowedDownloadHosts.
|
||
// - C4: saveManifest retries up to 3× before declaring desync.
|
||
// - scratch workspace resolves to writable runtime storage before staging.
|
||
// - L4: file written with 0755 during extraction, not chmod post-rename.
|
||
type GitHubUpdateExecutor struct {
|
||
Installer *GitHubInstaller
|
||
ScratchDir string // optional override; falls back to {runtimeDir}/tmp if unusable
|
||
}
|
||
|
||
// NewGitHubUpdateExecutor wires the executor.
|
||
func NewGitHubUpdateExecutor(installer *GitHubInstaller) *GitHubUpdateExecutor {
|
||
return &GitHubUpdateExecutor{Installer: installer}
|
||
}
|
||
|
||
// Source returns "github".
|
||
func (e *GitHubUpdateExecutor) Source() string { return "github" }
|
||
|
||
// scratchDirCandidates returns scratch parents in preference order.
|
||
func (e *GitHubUpdateExecutor) scratchDirCandidates() []string {
|
||
candidates := make([]string, 0, 3)
|
||
add := func(path string) {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return
|
||
}
|
||
cleaned := filepath.Clean(path)
|
||
for _, existing := range candidates {
|
||
if existing == cleaned {
|
||
return
|
||
}
|
||
}
|
||
candidates = append(candidates, cleaned)
|
||
}
|
||
|
||
add(e.ScratchDir)
|
||
add(filepath.Join(packageRuntimeDir(), "tmp"))
|
||
if e.Installer != nil && e.Installer.Config != nil && e.Installer.Config.BinDir != "" {
|
||
add(filepath.Join(filepath.Dir(e.Installer.Config.BinDir), "tmp"))
|
||
}
|
||
return candidates
|
||
}
|
||
|
||
func (e *GitHubUpdateExecutor) createScratchDir(name, target string) (string, error) {
|
||
suffix := fmt.Sprintf("%s-%s-%d", name, sanitizeTag(target), time.Now().UnixNano())
|
||
var errs []error
|
||
for idx, base := range e.scratchDirCandidates() {
|
||
scratch := filepath.Join(base, suffix)
|
||
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
||
errs = append(errs, fmt.Errorf("%s: %w", scratch, err))
|
||
continue
|
||
}
|
||
if idx > 0 {
|
||
slog.Warn("github.update: scratch dir fallback selected",
|
||
"scratch", scratch,
|
||
"previous_error", errs[0])
|
||
}
|
||
return scratch, nil
|
||
}
|
||
if len(errs) == 0 {
|
||
return "", errors.New("create scratch dir: no scratch directory candidates")
|
||
}
|
||
return "", fmt.Errorf("create scratch dir: %w", errors.Join(errs...))
|
||
}
|
||
|
||
// Update applies the target version. The caller holds PackageLocker for
|
||
// (source, name). See package doc for red-team fixes applied in-situ.
|
||
func (e *GitHubUpdateExecutor) Update(ctx context.Context, name, toVersion string, meta map[string]any) error {
|
||
if runtime.GOOS != "linux" {
|
||
return fmt.Errorf("%w (got %s)", ErrUnsupportedOS, runtime.GOOS)
|
||
}
|
||
if e.Installer == nil || e.Installer.Client == nil {
|
||
return errors.New("github update executor: installer not configured")
|
||
}
|
||
|
||
// Load manifest; locate entry by name.
|
||
m, err := e.Installer.loadManifest()
|
||
if err != nil {
|
||
return fmt.Errorf("load manifest: %w", err)
|
||
}
|
||
idx := findEntryByName(m, name)
|
||
if idx < 0 {
|
||
return fmt.Errorf("%w: %s", ErrPackageNotInstalled, name)
|
||
}
|
||
entry := m.Packages[idx]
|
||
|
||
owner, repo, ok := splitOwnerRepo(entry.Repo)
|
||
if !ok {
|
||
return fmt.Errorf("manifest entry has invalid repo: %q", entry.Repo)
|
||
}
|
||
|
||
// Resolve target tag: explicit toVersion OR fall back to meta LatestVersion.
|
||
target := toVersion
|
||
if target == "" {
|
||
if v, ok := metaString(meta, "latestVersion"); ok {
|
||
target = v
|
||
}
|
||
}
|
||
if target == "" {
|
||
return errors.New("github update executor: toVersion required (no meta)")
|
||
}
|
||
if target == entry.Tag {
|
||
// No-op — caller should have filtered, but handle gracefully.
|
||
return nil
|
||
}
|
||
|
||
// Resolve asset. Try meta first (fast path from check); verify host; refetch
|
||
// if stale or missing. C3 fix — cached asset URL is a hint, not a trust anchor.
|
||
assetURL, _ := metaString(meta, "assetURL")
|
||
assetName, _ := metaString(meta, "assetName")
|
||
assetSHA, _ := metaString(meta, "assetSHA256")
|
||
|
||
needRefetch := assetURL == "" || assetName == "" || assetSHA == ""
|
||
if !needRefetch {
|
||
if verr := validateDownloadURL(assetURL); verr != nil {
|
||
slog.Warn("github.update: cached assetURL rejected; refetching",
|
||
"name", name, "error", verr)
|
||
needRefetch = true
|
||
}
|
||
}
|
||
if needRefetch {
|
||
rel, _, _, ferr := e.Installer.Client.CondGetRelease(ctx, owner, repo, target, "")
|
||
if ferr != nil {
|
||
return fmt.Errorf("fetch release %s: %w", target, ferr)
|
||
}
|
||
if rel == nil {
|
||
return fmt.Errorf("%w: %s", ErrGitHubNotFound, target)
|
||
}
|
||
asset, aerr := SelectAsset(rel.Assets, "linux", runtime.GOARCH)
|
||
if aerr != nil {
|
||
return aerr
|
||
}
|
||
assetURL = asset.DownloadURL
|
||
assetName = asset.Name
|
||
// Opportunistically reload checksum from the release.
|
||
if assetSHA == "" {
|
||
assetSHA = findAssetSHA256(ctx, e.Installer.Client, rel, asset.Name)
|
||
}
|
||
// Final host validation (redirect case).
|
||
if verr := validateDownloadURL(assetURL); verr != nil {
|
||
return verr
|
||
}
|
||
}
|
||
|
||
// Prepare scratch dir — isolated per-update.
|
||
scratch, err := e.createScratchDir(name, target)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer os.RemoveAll(scratch)
|
||
|
||
// Download.
|
||
tmpArchive, sha, derr := e.Installer.Client.DownloadAsset(ctx, assetURL, e.Installer.Config.MaxAssetBytes())
|
||
if derr != nil {
|
||
return fmt.Errorf("download asset: %w", derr)
|
||
}
|
||
// Move archive into scratch so the defer cleans it up uniformly.
|
||
scratchArchive := filepath.Join(scratch, filepath.Base(tmpArchive))
|
||
if rerr := os.Rename(tmpArchive, scratchArchive); rerr != nil {
|
||
// Cross-device rename may fail — fall back to just using tmpArchive
|
||
// directly and remove it after.
|
||
scratchArchive = tmpArchive
|
||
defer os.Remove(tmpArchive)
|
||
}
|
||
|
||
// Verify SHA256 (constant-time) when publisher provides one.
|
||
if assetSHA != "" {
|
||
if verr := VerifyChecksum(assetSHA, sha); verr != nil {
|
||
return fmt.Errorf("%w: %v", ErrUpdateChecksumMismatch, verr)
|
||
}
|
||
} else {
|
||
slog.Info("github.update: no checksum available; proceeding without verification",
|
||
"asset", assetName)
|
||
}
|
||
|
||
// Extract.
|
||
files, eerr := ExtractArchiveAs(scratchArchive, repo, 2*e.Installer.Config.MaxAssetBytes())
|
||
if eerr != nil {
|
||
return fmt.Errorf("extract: %w", eerr)
|
||
}
|
||
binaries := pickBinaries(files, repo)
|
||
if len(binaries) == 0 {
|
||
return fmt.Errorf("%w: %s", ErrNoBinaryInArchive, assetName)
|
||
}
|
||
|
||
// ELF validate EVERY binary before swap.
|
||
for i := range binaries {
|
||
if verr := validateELF(binaries[i].Content); verr != nil {
|
||
return verr
|
||
}
|
||
}
|
||
|
||
// Stage all new binaries in scratch first with 0755 permissions (L4 —
|
||
// chmod BEFORE move, not after, to eliminate the exec-bit race).
|
||
staged := make(map[string]string, len(binaries)) // dest → stagedPath
|
||
binDir := e.Installer.Config.BinDir
|
||
for i := range binaries {
|
||
b := binaries[i]
|
||
base := filepath.Base(b.Name)
|
||
stagedPath := filepath.Join(scratch, "staged-"+base)
|
||
if werr := os.WriteFile(stagedPath, b.Content, 0o755); werr != nil {
|
||
return fmt.Errorf("stage %s: %w", base, werr)
|
||
}
|
||
staged[filepath.Join(binDir, base)] = stagedPath
|
||
}
|
||
|
||
// Acquire the installer's disk mutex for the swap + manifest save, since
|
||
// install/uninstall share the same bin dir.
|
||
e.Installer.mu.Lock()
|
||
defer e.Installer.mu.Unlock()
|
||
|
||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||
return fmt.Errorf("create bin dir: %w", err)
|
||
}
|
||
|
||
// ---- Two-phase atomic swap (C1) ----
|
||
//
|
||
// Phase A: rename ALL existing olds → .bak. If any fails, rollback all
|
||
// prior .bak renames and abort.
|
||
// Phase B: rename ALL news → dest. If any fails, restore all .bak files
|
||
// AND move any already-placed new into .failed-<ns> for forensics.
|
||
// On success: delete all .bak files.
|
||
|
||
type swapTarget struct {
|
||
dest string
|
||
backup string
|
||
newSrc string
|
||
hadBackup bool // review CRIT-3: distinguish real .bak from fresh-install sentinel
|
||
}
|
||
now := time.Now().UnixNano()
|
||
targets := make([]swapTarget, 0, len(staged))
|
||
for dest, src := range staged {
|
||
targets = append(targets, swapTarget{
|
||
dest: dest,
|
||
backup: fmt.Sprintf("%s.bak.%d", dest, now),
|
||
newSrc: src,
|
||
})
|
||
}
|
||
|
||
// Phase A — old → .bak
|
||
renamedA := make([]swapTarget, 0, len(targets))
|
||
rollbackA := func() {
|
||
// Only restore entries where we actually created a backup (CRIT-3);
|
||
// skipping the rest avoids spurious security.update.rollback_failed
|
||
// ENOENT alarms on fresh-install targets.
|
||
for _, t := range renamedA {
|
||
if !t.hadBackup {
|
||
continue
|
||
}
|
||
if rerr := os.Rename(t.backup, t.dest); rerr != nil {
|
||
slog.Error("security.update.rollback_failed",
|
||
"source", "github", "name", name,
|
||
"dest", t.dest, "backup", t.backup, "error", rerr)
|
||
}
|
||
}
|
||
}
|
||
for _, t := range targets {
|
||
if _, serr := os.Stat(t.dest); os.IsNotExist(serr) {
|
||
// Fresh install — no prior file. Mark hadBackup=false so rollback skips.
|
||
renamedA = append(renamedA, t)
|
||
continue
|
||
} else if serr != nil {
|
||
rollbackA()
|
||
return fmt.Errorf("%w: stat %s: %v", ErrUpdateSwapFailed, t.dest, serr)
|
||
}
|
||
if rerr := os.Rename(t.dest, t.backup); rerr != nil {
|
||
rollbackA()
|
||
return fmt.Errorf("%w: rename old→bak %s: %v", ErrUpdateSwapFailed, t.dest, rerr)
|
||
}
|
||
t.hadBackup = true
|
||
renamedA = append(renamedA, t)
|
||
}
|
||
|
||
// Phase B — new → dest
|
||
installedB := make([]swapTarget, 0, len(targets))
|
||
rollbackB := func() {
|
||
// Remove any successfully-placed new binaries (move to .failed-<ns>).
|
||
for _, t := range installedB {
|
||
failed := fmt.Sprintf("%s.failed-%d", t.dest, now)
|
||
if rerr := os.Rename(t.dest, failed); rerr != nil {
|
||
slog.Error("security.update.quarantine_failed",
|
||
"dest", t.dest, "target", failed, "error", rerr)
|
||
}
|
||
}
|
||
// Restore all .bak files.
|
||
rollbackA()
|
||
}
|
||
for _, t := range renamedA {
|
||
if rerr := os.Rename(t.newSrc, t.dest); rerr != nil {
|
||
rollbackB()
|
||
return fmt.Errorf("%w: rename new→dest %s: %v", ErrUpdateSwapFailed, t.dest, rerr)
|
||
}
|
||
installedB = append(installedB, t)
|
||
}
|
||
|
||
// Success — delete .bak files.
|
||
for _, t := range renamedA {
|
||
if _, serr := os.Stat(t.backup); serr == nil {
|
||
_ = os.Remove(t.backup)
|
||
}
|
||
}
|
||
|
||
// Update manifest entry in place.
|
||
entry.Tag = target
|
||
entry.SHA256 = sha
|
||
entry.AssetURL = assetURL
|
||
entry.AssetName = assetName
|
||
entry.InstalledAt = time.Now().UTC()
|
||
// Binaries list unchanged: we only re-install the same binary set the
|
||
// installer originally resolved. (Phase 2 pip/npm may change this.)
|
||
m.Packages[idx] = entry
|
||
|
||
// C4 — manifest save retry.
|
||
if err := e.saveManifestWithRetry(m); err != nil {
|
||
slog.Error("security.manifest.desync",
|
||
"source", "github", "name", name, "from", entry.Tag, "to", target, "error", err)
|
||
return fmt.Errorf("%w: %v", ErrUpdateManifestDesync, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// saveManifestWithRetry attempts 3 atomic writes with backoff.
|
||
func (e *GitHubUpdateExecutor) saveManifestWithRetry(m *GitHubManifest) error {
|
||
var lastErr error
|
||
backoffs := []time.Duration{100 * time.Millisecond, 500 * time.Millisecond, time.Second}
|
||
for _, b := range backoffs {
|
||
if err := e.Installer.saveManifest(m); err == nil {
|
||
return nil
|
||
} else {
|
||
lastErr = err
|
||
time.Sleep(b)
|
||
}
|
||
}
|
||
return lastErr
|
||
}
|
||
|
||
// findEntryByName returns index of the entry with matching Name, or -1.
|
||
func findEntryByName(m *GitHubManifest, name string) int {
|
||
for i := range m.Packages {
|
||
if m.Packages[i].Name == name {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// metaString extracts a string value from meta, returning (value, present).
|
||
// Missing or wrong type returns ("", false) — never panics (C6 nil-safe).
|
||
func metaString(m map[string]any, key string) (string, bool) {
|
||
if m == nil {
|
||
return "", false
|
||
}
|
||
v, ok := m[key]
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
return s, true
|
||
}
|
||
|
||
// sanitizeTag makes a tag string safe for use in filesystem paths.
|
||
// Replaces any non-alphanumeric/dot/underscore/hyphen with '-'.
|
||
func sanitizeTag(tag string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(tag))
|
||
for _, r := range tag {
|
||
switch {
|
||
case r >= '0' && r <= '9',
|
||
r >= 'A' && r <= 'Z',
|
||
r >= 'a' && r <= 'z',
|
||
r == '.' || r == '_' || r == '-':
|
||
b.WriteRune(r)
|
||
default:
|
||
b.WriteRune('-')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|