Files

638 lines
20 KiB
Go

package skills
import (
"bytes"
"context"
"debug/elf"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"time"
)
// Sentinel errors for the GitHub installer.
var (
ErrInvalidGitHubSpec = errors.New("github: invalid spec (expected github:owner/repo[@tag])")
ErrGitHubOrgNotAllowed = errors.New("github: org not in allowlist")
ErrNoMatchingAsset = errors.New("github: no matching asset for runtime")
ErrNotELF = errors.New("github: not an ELF binary")
ErrUnsupportedELFClass = errors.New("github: only 64-bit ELF supported")
ErrELFArchMismatch = errors.New("github: ELF architecture mismatch")
ErrNoBinaryInArchive = errors.New("github: no executable found in archive")
ErrPackageNotInstalled = errors.New("github: package not installed")
ErrUnsupportedOS = errors.New("github: install only supported on Linux")
)
// GitHubSpec is the parsed form of a "github:owner/repo[@tag]" identifier.
type GitHubSpec struct {
Owner string
Repo string
Tag string // empty string means "latest"
}
// gitHubSpecRE validates the supported identifier format.
// Owner: GitHub usernames are capped at 39 chars, alnum + hyphen, no leading/trailing hyphen.
// Repo: alnum + `.`/`_`/`-`.
// Tag: capped at 255 chars (git ref-name upper bound), excludes NUL/whitespace.
var gitHubSpecRE = regexp.MustCompile(`^github:([A-Za-z0-9](?:[A-Za-z0-9-]{0,37})?[A-Za-z0-9]|[A-Za-z0-9])/([A-Za-z0-9][A-Za-z0-9._-]*)(?:@([^\s\x00]{1,255}))?$`)
// ParseGitHubSpec parses an identifier string.
func ParseGitHubSpec(s string) (*GitHubSpec, error) {
m := gitHubSpecRE.FindStringSubmatch(s)
if m == nil {
return nil, ErrInvalidGitHubSpec
}
return &GitHubSpec{Owner: m[1], Repo: m[2], Tag: m[3]}, nil
}
// GitHubPackagesConfig holds tunables for the installer.
// Token is sourced from env var only (never config.json plaintext).
type GitHubPackagesConfig struct {
Token string // optional GitHub personal access token
BinDir string // where to install binaries (default {runtimeDir}/bin)
ManifestPath string // manifest file path (default {BinDir}/../github-packages.json)
AllowedOrgs []string // lowercase list; empty = all allowed
MaxAssetSizeMB int // default 200
}
// Defaults fills in zero-valued fields.
func (c *GitHubPackagesConfig) Defaults() {
if c.BinDir == "" {
c.BinDir = filepath.Join(packageRuntimeDir(), "bin")
}
if c.ManifestPath == "" {
c.ManifestPath = filepath.Join(filepath.Dir(c.BinDir), "github-packages.json")
}
if c.MaxAssetSizeMB <= 0 {
c.MaxAssetSizeMB = 200
}
// Normalize allowed orgs to lowercase, trim whitespace, drop empties.
out := c.AllowedOrgs[:0]
for _, o := range c.AllowedOrgs {
o = strings.ToLower(strings.TrimSpace(o))
if o != "" {
out = append(out, o)
}
}
c.AllowedOrgs = out
}
// MaxAssetBytes returns MaxAssetSizeMB as a byte count.
func (c *GitHubPackagesConfig) MaxAssetBytes() int64 {
return int64(c.MaxAssetSizeMB) * 1024 * 1024
}
// GitHubInstaller orchestrates end-to-end install + uninstall + list.
type GitHubInstaller struct {
Client *GitHubClient
Config *GitHubPackagesConfig
// Locker serializes install/update/uninstall on the same package across
// the whole installer (shared with update executor). If nil, a process-
// local locker is used.
Locker *PackageLocker
mu sync.Mutex // serializes the final disk-write phase: bin dir writes + manifest mutation
// (download, extraction, and ELF validation intentionally run outside the lock)
}
// NewGitHubInstaller constructs an installer.
func NewGitHubInstaller(client *GitHubClient, cfg *GitHubPackagesConfig) *GitHubInstaller {
if cfg == nil {
cfg = &GitHubPackagesConfig{}
}
cfg.Defaults()
return &GitHubInstaller{Client: client, Config: cfg, Locker: NewPackageLocker()}
}
// SetLocker swaps the package locker. Used to share a locker across the
// installer and the update executor so install+update serialize on the
// same package key. Safe to call at setup time only.
func (i *GitHubInstaller) SetLocker(l *PackageLocker) {
if l != nil {
i.Locker = l
}
}
// AllowedOrg returns true if owner passes allowlist (empty slice = all allowed).
func (i *GitHubInstaller) AllowedOrg(owner string) bool {
if len(i.Config.AllowedOrgs) == 0 {
return true
}
owner = strings.ToLower(owner)
for _, a := range i.Config.AllowedOrgs {
if a == owner {
return true
}
}
return false
}
// -------- Asset selection --------
var (
excludeSuffixRE = regexp.MustCompile(`(?i)\.(sha256|sig|asc|minisig|pem|pub|cert|crt)$`)
excludeNameRE = regexp.MustCompile(`(?i)(source[\s_-]?code|source\.tar\.gz|source\.zip)`)
linuxRE = regexp.MustCompile(`(?i)linux`)
amd64RE = regexp.MustCompile(`(?i)(amd64|x86[-_]?64|x64)`)
arm64RE = regexp.MustCompile(`(?i)(arm64|aarch64)`)
)
// SelectAsset picks the best asset for target OS + arch.
// Heuristic (in order):
// 1. exclude checksum/signature suffix files
// 2. exclude "source code" archives
// 3. filter by OS ("linux")
// 4. filter by arch
// 5. prefer .tar.gz/.tgz > .zip > raw
// 6. tiebreak by shortest name
func SelectAsset(assets []GitHubAsset, goos, goarch string) (*GitHubAsset, error) {
candidates := make([]GitHubAsset, 0, len(assets))
for _, a := range assets {
if excludeSuffixRE.MatchString(a.Name) {
continue
}
if excludeNameRE.MatchString(a.Name) {
continue
}
candidates = append(candidates, a)
}
if goos == "linux" {
candidates = filterAssets(candidates, linuxRE)
}
switch goarch {
case "amd64":
candidates = filterAssets(candidates, amd64RE)
case "arm64":
candidates = filterAssets(candidates, arm64RE)
}
if len(candidates) == 0 {
return nil, enrichNoMatch(assets, goos, goarch)
}
sort.SliceStable(candidates, func(i, j int) bool {
pi, pj := extPriority(candidates[i].Name), extPriority(candidates[j].Name)
if pi != pj {
return pi < pj
}
return len(candidates[i].Name) < len(candidates[j].Name)
})
pick := candidates[0]
return &pick, nil
}
func filterAssets(in []GitHubAsset, re *regexp.Regexp) []GitHubAsset {
out := in[:0:0]
for _, a := range in {
if re.MatchString(a.Name) {
out = append(out, a)
}
}
return out
}
// extPriority gives lower numbers to preferred archive formats.
func extPriority(name string) int {
n := strings.ToLower(name)
switch {
case strings.HasSuffix(n, ".tar.gz"), strings.HasSuffix(n, ".tgz"):
return 0
case strings.HasSuffix(n, ".zip"):
return 1
default:
return 2
}
}
func enrichNoMatch(all []GitHubAsset, goos, goarch string) error {
names := make([]string, 0, len(all))
for _, a := range all {
names = append(names, a.Name)
}
return fmt.Errorf("%w (os=%s, arch=%s); available: %s",
ErrNoMatchingAsset, goos, goarch, strings.Join(names, ", "))
}
// -------- Manifest --------
// GitHubPackageEntry records metadata about an installed package.
//
// Note: there is no InstalledBy field — the install call doesn't currently
// thread a user ID through, and an always-empty audit field is more misleading
// than useful. Add it back if/when the request context is plumbed in.
type GitHubPackageEntry struct {
Name string `json:"name"`
Repo string `json:"repo"`
Tag string `json:"tag"`
Binaries []string `json:"binaries"`
SHA256 string `json:"sha256"`
AssetURL string `json:"asset_url"`
AssetName string `json:"asset_name"`
AssetSizeBytes int64 `json:"asset_size_bytes"`
InstalledAt time.Time `json:"installed_at"`
}
// GitHubManifest is the persisted state for installed GitHub packages.
type GitHubManifest struct {
Version int `json:"version"`
Packages []GitHubPackageEntry `json:"packages"`
}
// loadManifest returns an empty manifest if file missing.
func (i *GitHubInstaller) loadManifest() (*GitHubManifest, error) {
b, err := os.ReadFile(i.Config.ManifestPath)
if err != nil {
if os.IsNotExist(err) {
return &GitHubManifest{Version: 1}, nil
}
return nil, err
}
var m GitHubManifest
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
if m.Version == 0 {
m.Version = 1
}
return &m, nil
}
// saveManifest writes atomically via temp + fsync + rename + dir fsync.
// The two fsyncs ensure durability across crashes/power-loss: without
// file fsync the rename commits a possibly-empty inode; without dir fsync
// the rename itself may be reordered on XFS/ext4 with journal-async.
func (i *GitHubInstaller) saveManifest(m *GitHubManifest) error {
dir := filepath.Dir(i.Config.ManifestPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tmp := i.Config.ManifestPath + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, i.Config.ManifestPath); err != nil {
os.Remove(tmp)
return err
}
// Best-effort dir fsync — opening a dir as O_RDONLY works on Linux/macOS
// but some filesystems (e.g. Windows via WSL paths) may reject it. We
// log and proceed — the rename has already happened.
if d, derr := os.Open(dir); derr == nil {
_ = d.Sync()
d.Close()
}
return nil
}
// List returns all installed packages from manifest.
func (i *GitHubInstaller) List() ([]GitHubPackageEntry, error) {
i.mu.Lock()
defer i.mu.Unlock()
m, err := i.loadManifest()
if err != nil {
return nil, err
}
return m.Packages, nil
}
// -------- ELF validation --------
// validateELF checks magic bytes, 64-bit class, and machine matches runtime.
func validateELF(content []byte) error {
if len(content) < 4 {
return ErrNotELF
}
if !bytes.Equal(content[:4], []byte{0x7f, 0x45, 0x4c, 0x46}) {
return ErrNotELF
}
f, err := elf.NewFile(bytes.NewReader(content))
if err != nil {
return fmt.Errorf("invalid ELF: %w", err)
}
defer f.Close()
if f.Class != elf.ELFCLASS64 {
return ErrUnsupportedELFClass
}
wantMachine := elf.EM_X86_64
if runtime.GOARCH == "arm64" {
wantMachine = elf.EM_AARCH64
}
if f.Machine != wantMachine {
return fmt.Errorf("%w (binary=%v, runtime=%v)", ErrELFArchMismatch, f.Machine, wantMachine)
}
return nil
}
// -------- Binary picker --------
var nonBinaryPathRE = regexp.MustCompile(`(?i)(^|/)(man|docs?|contrib|completions|examples?|tests?|licenses?)/`)
// pickBinaries selects executable entries from an extracted archive.
// Preference order:
// 1. entries with basename == repo name (common case: `lazygit` binary in lazygit archive)
// 2. all executable entries whose path doesn't match nonBinaryPathRE
// (man/docs/contrib/completions/examples/tests/licenses are excluded)
// 3. any ELF-magic entry under a non-excluded path
func pickBinaries(files []ArchiveFile, repoName string) []ArchiveFile {
// Filter out clearly-not-binary paths first.
var candidates []ArchiveFile
for _, f := range files {
if nonBinaryPathRE.MatchString(f.Name) {
continue
}
candidates = append(candidates, f)
}
// Try exact basename match to repo name.
var named []ArchiveFile
for _, f := range candidates {
base := filepath.Base(f.Name)
if base == repoName {
named = append(named, f)
}
}
if len(named) > 0 {
return named
}
// Otherwise: any executable-looking entry that survived the nonBinaryPathRE
// filter above. Depth is not enforced here — ELF validation in the caller
// is the final gate before chmod +x.
var execs []ArchiveFile
for _, f := range candidates {
if isLikelyExecutable(f) {
execs = append(execs, f)
}
}
return execs
}
func isLikelyExecutable(f ArchiveFile) bool {
// Executable bit set OR ELF magic present.
if f.Mode&0o111 != 0 {
return true
}
if len(f.Content) >= 4 && bytes.Equal(f.Content[:4], []byte{0x7f, 0x45, 0x4c, 0x46}) {
return true
}
return false
}
// -------- Install + Uninstall --------
// Install runs the full pipeline: parse → check org → fetch release → select asset →
// download → verify → extract → validate ELF → write to bin dir → update manifest.
func (i *GitHubInstaller) Install(ctx context.Context, spec string) (*GitHubPackageEntry, error) {
// The installer ships only Linux ELF asset selection + validation. Guard
// non-Linux callers (Windows/macOS desktop host) up front so we don't
// waste bandwidth fetching a Linux asset that's going to be rejected at
// the ELF-machine check later.
if runtime.GOOS != "linux" {
return nil, fmt.Errorf("%w (got %s)", ErrUnsupportedOS, runtime.GOOS)
}
parsed, err := ParseGitHubSpec(spec)
if err != nil {
return nil, err
}
if !i.AllowedOrg(parsed.Owner) {
return nil, fmt.Errorf("%w: %s", ErrGitHubOrgNotAllowed, parsed.Owner)
}
// Package-level lock: serializes concurrent install+update+uninstall of
// the SAME package across both HTTP handlers and the update executor.
// The canonical package name depends on the chosen binaries (see
// canonicalPackageName below) so key by repo here — both install paths
// and the executor key off repo for parity.
if i.Locker != nil {
unlock, lerr := i.Locker.Acquire(ctx, "github", parsed.Repo)
if lerr != nil {
return nil, fmt.Errorf("github: acquire lock: %w", lerr)
}
defer unlock()
}
release, err := i.Client.GetRelease(ctx, parsed.Owner, parsed.Repo, parsed.Tag)
if err != nil {
return nil, err
}
asset, err := SelectAsset(release.Assets, "linux", runtime.GOARCH)
if err != nil {
return nil, err
}
maxBytes := i.Config.MaxAssetBytes()
tmpPath, sha, err := i.Client.DownloadAsset(ctx, asset.DownloadURL, maxBytes)
if err != nil {
return nil, err
}
defer os.Remove(tmpPath)
// Checksum verification (if publisher provides it).
// Failure modes that silently proceed are noisy-logged so an operator can
// detect a modified checksum file being served alongside a tampered asset.
if ca := FindChecksumAsset(release, asset.Name); ca != nil {
checksumPath, _, cerr := i.Client.DownloadAsset(ctx, ca.DownloadURL, 1<<20) // 1 MiB cap
if cerr == nil {
defer os.Remove(checksumPath)
data, rerr := os.ReadFile(checksumPath)
if rerr != nil {
slog.Warn("github.installer: read checksum file failed",
"checksum_asset", ca.Name, "error", rerr)
} else {
sums, perr := ParseChecksums(data)
if perr != nil {
slog.Warn("github.installer: parse checksum file failed",
"checksum_asset", ca.Name, "error", perr)
} else if expected, ok := sums[asset.Name]; ok {
if verr := VerifyChecksum(expected, sha); verr != nil {
return nil, verr
}
} else {
// Publisher ships a checksum file that omits this asset.
// Could be benign (asset added later, different file set)
// or a MITM replacing the checksum file with an entry-free
// one. We warn loudly; ELF validation remains the final gate.
slog.Warn("github.installer: asset not listed in checksum file",
"asset", asset.Name, "checksum_asset", ca.Name)
}
}
} else {
slog.Warn("github.installer: failed to fetch checksum file", "error", cerr)
}
} else {
// Not a problem with the install — many upstream publishers simply
// don't ship checksum files (jq, fzf, older ripgrep, etc.). Downgraded
// from Warn so the suspicious cases (read/parse/asset-not-listed
// errors, which stay at Warn above) stand out cleanly.
slog.Info("github.installer: no checksum asset available", "asset", asset.Name)
}
// Pass the repo name as the fallback logical name so raw (non-archive)
// ELF assets don't end up recorded under the temp filename
// "goclaw-gh-asset-XXXX.bin".
files, err := ExtractArchiveAs(tmpPath, parsed.Repo, 2*maxBytes)
if err != nil {
return nil, err
}
binaries := pickBinaries(files, parsed.Repo)
if len(binaries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrNoBinaryInArchive, asset.Name)
}
for idx := range binaries {
if err := validateELF(binaries[idx].Content); err != nil {
return nil, err
}
}
// Commit to disk under lock.
i.mu.Lock()
defer i.mu.Unlock()
if err := os.MkdirAll(i.Config.BinDir, 0o755); err != nil {
return nil, fmt.Errorf("create bin dir: %w", err)
}
// Load manifest first so we can detect basename collisions with other
// installed packages. Current policy is still last-writer-wins (changing
// that would break re-install), but an operator needs to know when two
// packages are fighting over the same binary name.
m, err := i.loadManifest()
if err != nil {
return nil, err
}
entryRepo := parsed.Owner + "/" + parsed.Repo
binNames := make([]string, 0, len(binaries))
for _, b := range binaries {
name := filepath.Base(b.Name)
// Warn if another package in the manifest already owns this binary name.
for _, p := range m.Packages {
if !strings.EqualFold(p.Repo, entryRepo) {
for _, pb := range p.Binaries {
if pb == name {
slog.Warn("github.installer: binary name collision — overwriting",
"binary", name, "existing_package", p.Name,
"existing_repo", p.Repo, "new_repo", entryRepo)
}
}
}
}
dst := filepath.Join(i.Config.BinDir, name)
if err := os.WriteFile(dst, b.Content, 0o755); err != nil {
return nil, fmt.Errorf("write binary %s: %w", name, err)
}
binNames = append(binNames, name)
}
entry := GitHubPackageEntry{
Name: canonicalPackageName(parsed, binNames),
Repo: entryRepo,
Tag: release.TagName,
Binaries: binNames,
SHA256: sha,
AssetURL: asset.DownloadURL,
AssetName: asset.Name,
AssetSizeBytes: asset.SizeBytes,
InstalledAt: time.Now().UTC(),
}
// Replace existing entry with same name.
found := false
for idx := range m.Packages {
if m.Packages[idx].Name == entry.Name {
m.Packages[idx] = entry
found = true
break
}
}
if !found {
m.Packages = append(m.Packages, entry)
}
if err := i.saveManifest(m); err != nil {
return nil, err
}
return &entry, nil
}
// canonicalPackageName uses repo name unless a single binary has a different name.
func canonicalPackageName(spec *GitHubSpec, binNames []string) string {
if len(binNames) == 1 && binNames[0] != spec.Repo {
return binNames[0]
}
return spec.Repo
}
// Uninstall removes installed binaries + manifest entry.
// name matches GitHubPackageEntry.Name.
func (i *GitHubInstaller) Uninstall(ctx context.Context, name string) error {
_ = ctx
i.mu.Lock()
defer i.mu.Unlock()
m, err := i.loadManifest()
if err != nil {
return err
}
idx := -1
for k, p := range m.Packages {
if p.Name == name {
idx = k
break
}
}
if idx < 0 {
return ErrPackageNotInstalled
}
binaries := m.Packages[idx].Binaries
// Persist the manifest BEFORE touching the files on disk. If saveManifest
// fails we bail out without orphaning binaries on a manifest that still
// claims them as installed (a retried Uninstall would otherwise hit
// ErrPackageNotInstalled after the first attempt wiped the files).
m.Packages = append(m.Packages[:idx], m.Packages[idx+1:]...)
if err := i.saveManifest(m); err != nil {
return err
}
// Remove-after-save is best-effort: a missing file is fine (idempotent),
// any other error is warned and the manifest still reflects the truth
// that the entry is no longer tracked.
for _, b := range binaries {
// Only remove files within our configured bin dir (defense in depth).
path := filepath.Join(i.Config.BinDir, filepath.Base(b))
if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) {
slog.Warn("github.installer: remove binary failed", "path", path, "error", rerr)
}
}
return nil
}