Files
kandev/apps/backend/internal/plugins/pkgtar/pkgtar.go
T
Kandev Agent c53f699714 feat: add plugin system with grpc runtime and native ui plugins
- go plugin backends ship as release tarballs (per-platform binaries, checksums) that
  kandev installs from url/upload or filesystem sideload+sync, then spawns and
  supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls)
- strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook
  plus a capability-gated host service (state, secrets, emit); author sdk in
  pkg/pluginsdk; packaging cli in cmd/plugin-pack
- native frontend bundles: window.registerKandevPlugin + registry/host loader with
  dynamic routes, nav items, slots and ws handlers; plugins settings page with
  install dialog, sync button and enable/disable/uninstall
- gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and
  integration tests plus playwright e2e against a real packaged fixture plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:55:45 +01:00

470 lines
16 KiB
Go

// Package pkgtar implements the kandev plugin package format described in
// docs/plans/plugins/GRPC-CONTRACT.md §6: a tar.gz archive containing
// manifest.yaml, a set of per-platform server/ executables, an optional
// ui/ bundle, and a checksums.txt covering every other file. Inspect reads
// only the manifest (no disk writes); Install verifies and atomically
// extracts a package into a per-plugin, per-version directory; Remove
// deletes an installed plugin's directory tree.
package pkgtar
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"github.com/kandev/kandev/internal/plugins/manifest"
)
const (
manifestFileName = "manifest.yaml"
checksumsFileName = "checksums.txt"
checksumsSigFileName = "checksums.txt.sig"
// maxManifestSize caps how much of manifest.yaml Inspect will read into
// memory before giving up.
maxManifestSize = 1 << 20 // 1 MiB
// maxPackageFileSize caps any single file's size during Install.
maxPackageFileSize = 200 << 20 // 200 MiB
// maxPackageTotalSize caps the sum of all file sizes during Install.
maxPackageTotalSize = 200 << 20 // 200 MiB
)
// Sentinel errors returned (wrapped) by Install. Use errors.Is to check for
// a specific failure category.
var (
// ErrManifestInvalid means manifest.yaml is missing, fails to parse,
// fails manifest.Validate(), or is not a runtime-managed manifest
// (manifest.IsManaged() == false).
ErrManifestInvalid = errors.New("pkgtar: manifest invalid")
// ErrMissingChecksums means the package has no checksums.txt entry.
ErrMissingChecksums = errors.New("pkgtar: missing checksums.txt")
// ErrUnlistedFile means the package contains a file that checksums.txt
// does not list.
ErrUnlistedFile = errors.New("pkgtar: file not listed in checksums.txt")
// ErrChecksumMismatch means a file's sha256 does not match the value
// recorded in checksums.txt.
ErrChecksumMismatch = errors.New("pkgtar: checksum mismatch")
// ErrPathTraversal means an archive entry's path escapes the package
// root (absolute path, "..", or a symlink/hardlink entry).
ErrPathTraversal = errors.New("pkgtar: unsafe archive path")
// ErrPlatformNotSupported means the manifest does not declare an
// executable for the current host's runtime.GOOS-runtime.GOARCH.
ErrPlatformNotSupported = errors.New("pkgtar: host platform not supported by package")
// ErrVersionExists means destRoot/<id>/<version> already exists.
ErrVersionExists = errors.New("pkgtar: version already installed")
)
// VerifySignature is an injectable hook for verifying checksums.txt.sig
// against the checksums.txt bytes it signs. It is nil by default, meaning
// signature verification is currently a stub: a present checksums.txt.sig
// entry only sets InstallResult.Signed=true, it is not cryptographically
// verified. Set this (e.g. to an ed25519 verifier) to enable real
// verification; a non-nil error from it fails Install.
var VerifySignature func(sig, checksums []byte) error
// InstallResult describes the outcome of a successful Install.
type InstallResult struct {
// Manifest is the parsed, validated manifest.yaml of the installed
// package.
Manifest *manifest.Manifest
// InstallPath is the absolute path the package was extracted to:
// destRoot/<id>/<version>.
InstallPath string
// Version is Manifest.Version, duplicated here for convenience.
Version string
// Signed reports whether the package included a checksums.txt.sig
// entry. See VerifySignature for what "signed" currently guarantees.
Signed bool
}
// Inspect streams r (a kandev plugin tar.gz package), extracts only
// manifest.yaml into memory (capped at maxManifestSize), and parses +
// validates it. It performs no checksum verification and writes nothing to
// disk; it exists to preview a package's declared capabilities before
// installing it.
func Inspect(r io.Reader) (*manifest.Manifest, error) {
tr, closeReader, err := openTarGz(r)
if err != nil {
return nil, err
}
defer closeReader()
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("pkgtar: %s not found in package", manifestFileName)
}
if err != nil {
return nil, fmt.Errorf("pkgtar: reading tar entry: %w", err)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
name, err := cleanArchivePath(hdr.Name)
if err != nil || name != manifestFileName {
continue
}
return parseManifestEntry(tr, hdr.Size)
}
}
// parseManifestEntry reads a manifest.yaml tar entry (capped at
// maxManifestSize) and parses + validates it.
func parseManifestEntry(tr *tar.Reader, size int64) (*manifest.Manifest, error) {
if size > maxManifestSize {
return nil, fmt.Errorf("pkgtar: %s exceeds max size of %d bytes", manifestFileName, maxManifestSize)
}
data, err := readCapped(tr, maxManifestSize)
if err != nil {
return nil, fmt.Errorf("pkgtar: reading %s: %w", manifestFileName, err)
}
m, err := manifest.Parse(data)
if err != nil {
return nil, fmt.Errorf("pkgtar: parsing %s: %w", manifestFileName, err)
}
if err := m.Validate(); err != nil {
return nil, fmt.Errorf("pkgtar: invalid %s: %w", manifestFileName, err)
}
return m, nil
}
// Install verifies and atomically extracts a kandev plugin package (tar.gz,
// read fully from r) into destRoot/<id>/<version>/, per
// docs/plans/plugins/GRPC-CONTRACT.md §6:
//
// 1. Read the whole archive (size-capped, path-traversal-rejected).
// 2. Require checksums.txt covering every other file; verify every hash
// and reject unlisted files.
// 3. Parse + validate manifest.yaml; require it to be runtime-managed.
// 4. Require an executable declared for the current host platform.
// 5. Extract to a temp dir under destRoot/<id>/, chmod declared
// executables to 0755, then atomically rename into place. Fails with
// ErrVersionExists if destRoot/<id>/<version> already exists.
func Install(r io.Reader, destRoot string) (*InstallResult, error) {
files, err := readArchive(r)
if err != nil {
return nil, err
}
signed, err := verifyPackageIntegrity(files)
if err != nil {
return nil, err
}
m, execPath, err := validateInstallManifest(files)
if err != nil {
return nil, err
}
versionDir, err := extractPackage(destRoot, m, files, execPath)
if err != nil {
return nil, err
}
return &InstallResult{
Manifest: m,
InstallPath: versionDir,
Version: m.Version,
Signed: signed,
}, nil
}
// verifyPackageIntegrity requires checksums.txt, verifies every listed
// file's hash, rejects unlisted files, and (when present) notes
// checksums.txt.sig and runs VerifySignature.
func verifyPackageIntegrity(files map[string][]byte) (signed bool, err error) {
checksumsData, ok := files[checksumsFileName]
if !ok {
return false, ErrMissingChecksums
}
checks, err := parseChecksums(checksumsData)
if err != nil {
return false, err
}
if err := verifyChecksums(files, checks); err != nil {
return false, err
}
sigData, hasSig := files[checksumsSigFileName]
if !hasSig {
return false, nil
}
if VerifySignature != nil {
if err := VerifySignature(sigData, checksumsData); err != nil {
return true, fmt.Errorf("pkgtar: signature verification failed: %w", err)
}
}
return true, nil
}
// validateInstallManifest parses manifest.yaml out of files, validates it,
// requires it to be runtime-managed, and resolves the current host
// platform's executable path (which must itself be present in files).
func validateInstallManifest(files map[string][]byte) (*manifest.Manifest, string, error) {
manifestData, ok := files[manifestFileName]
if !ok {
return nil, "", fmt.Errorf("%w: missing %s", ErrManifestInvalid, manifestFileName)
}
m, err := manifest.Parse(manifestData)
if err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrManifestInvalid, err)
}
if err := m.Validate(); err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrManifestInvalid, err)
}
if !m.IsManaged() {
return nil, "", fmt.Errorf("%w: manifest is not runtime-managed (runtime.type must be \"binary\")", ErrManifestInvalid)
}
execPath, ok := m.ExecutableFor(runtime.GOOS, runtime.GOARCH)
if !ok {
return nil, "", fmt.Errorf("%w: %s-%s", ErrPlatformNotSupported, runtime.GOOS, runtime.GOARCH)
}
if _, ok := files[execPath]; !ok {
return nil, "", fmt.Errorf("%w: declared executable %q not found in package", ErrManifestInvalid, execPath)
}
return m, execPath, nil
}
// extractPackage writes files into a temp dir under destRoot/<id>/, chmods
// every declared runtime executable to 0755, and atomically renames the
// temp dir to destRoot/<id>/<version>. It fails with ErrVersionExists if
// that directory already exists, and cleans up the temp dir on any error.
func extractPackage(destRoot string, m *manifest.Manifest, files map[string][]byte, hostExecPath string) (string, error) {
pluginDir := filepath.Join(destRoot, m.ID)
versionDir := filepath.Join(pluginDir, m.Version)
if _, err := os.Stat(versionDir); err == nil {
return "", fmt.Errorf("%w: %s", ErrVersionExists, versionDir)
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("pkgtar: checking install path: %w", err)
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return "", fmt.Errorf("pkgtar: creating plugin dir: %w", err)
}
tmpDir, err := os.MkdirTemp(pluginDir, ".tmp-*")
if err != nil {
return "", fmt.Errorf("pkgtar: creating temp dir: %w", err)
}
succeeded := false
defer func() {
if !succeeded {
_ = os.RemoveAll(tmpDir)
}
}()
_ = hostExecPath // the whole executables set is chmodded, not just the host one
execSet := executablePaths(m)
if err := writePackageFiles(tmpDir, files, execSet); err != nil {
return "", err
}
if err := os.Rename(tmpDir, versionDir); err != nil {
return "", fmt.Errorf("pkgtar: finalizing install: %w", err)
}
succeeded = true
return versionDir, nil
}
// writePackageFiles writes every entry in files under root, creating
// parent directories as needed, and chmods entries listed in execSet to
// 0755.
func writePackageFiles(root string, files map[string][]byte, execSet map[string]bool) error {
for name, data := range files {
dest := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("pkgtar: creating dir for %s: %w", name, err)
}
mode := os.FileMode(0o644)
if execSet[name] {
mode = 0o755
}
if err := os.WriteFile(dest, data, mode); err != nil {
return fmt.Errorf("pkgtar: writing %s: %w", name, err)
}
}
return nil
}
// executablePaths returns the set of package-relative paths declared by
// any of the manifest's runtime.executables entries (not just the host
// platform's), so a multi-platform package has every included executable
// chmodded regardless of which one the host will run.
func executablePaths(m *manifest.Manifest) map[string]bool {
set := make(map[string]bool, len(m.Runtime.Executables))
for _, p := range m.Runtime.Executables {
set[p] = true
}
return set
}
// Remove deletes destRoot/<id>/ entirely: every installed version plus the
// plugin's data directory.
func Remove(destRoot, id string) error {
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, "/\\") {
return fmt.Errorf("pkgtar: invalid plugin id %q", id)
}
dir := filepath.Join(destRoot, id)
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("pkgtar: removing %s: %w", dir, err)
}
return nil
}
// readArchive fully decompresses and un-tars r into a name->contents map,
// enforcing per-file and total size caps and rejecting any entry that is
// not a plain file at a clean, package-relative path (no symlinks,
// hardlinks, absolute paths, or "..").
func readArchive(r io.Reader) (map[string][]byte, error) {
tr, closeReader, err := openTarGz(r)
if err != nil {
return nil, err
}
defer closeReader()
files := make(map[string][]byte)
var total int64
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("pkgtar: reading tar entry: %w", err)
}
switch hdr.Typeflag {
case tar.TypeDir:
continue
case tar.TypeReg:
// handled below
default:
return nil, fmt.Errorf("%w: entry %q has unsupported type (symlinks/hardlinks/devices are not allowed)", ErrPathTraversal, hdr.Name)
}
name, err := cleanArchivePath(hdr.Name)
if err != nil {
return nil, err
}
if hdr.Size > maxPackageFileSize {
return nil, fmt.Errorf("pkgtar: file %s exceeds max size of %d bytes", name, maxPackageFileSize)
}
total += hdr.Size
if total > maxPackageTotalSize {
return nil, fmt.Errorf("pkgtar: package exceeds max total size of %d bytes", maxPackageTotalSize)
}
data, err := readCapped(tr, maxPackageFileSize)
if err != nil {
return nil, fmt.Errorf("pkgtar: reading %s: %w", name, err)
}
files[name] = data
}
return files, nil
}
// openTarGz wraps r in a gzip reader and a tar reader, returning a closer
// that releases the gzip reader.
func openTarGz(r io.Reader) (*tar.Reader, func(), error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, nil, fmt.Errorf("pkgtar: invalid gzip stream: %w", err)
}
return tar.NewReader(gz), func() { _ = gz.Close() }, nil
}
// readCapped reads at most maxSize+1 bytes from r and errors if that many
// were available, i.e. the entry exceeds maxSize bytes.
func readCapped(r io.Reader, maxSize int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxSize {
return nil, fmt.Errorf("entry exceeds max size of %d bytes", maxSize)
}
return data, nil
}
// cleanArchivePath cleans and validates a tar entry name: it must not be
// empty, absolute, or escape the package root via "..".
func cleanArchivePath(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("%w: empty entry name", ErrPathTraversal)
}
if path.IsAbs(name) {
return "", fmt.Errorf("%w: absolute path %q", ErrPathTraversal, name)
}
cleaned := path.Clean(name)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) {
return "", fmt.Errorf("%w: %q", ErrPathTraversal, name)
}
return cleaned, nil
}
// parseChecksums parses "sha256 path" lines (sha256sum-style: hex digest,
// whitespace, path) into a map of clean path -> lowercase hex digest.
func parseChecksums(data []byte) (map[string]string, error) {
checks := make(map[string]string)
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) != 2 {
return nil, fmt.Errorf("pkgtar: malformed %s line: %q", checksumsFileName, line)
}
name, err := cleanArchivePath(fields[1])
if err != nil {
return nil, err
}
checks[name] = strings.ToLower(fields[0])
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("pkgtar: reading %s: %w", checksumsFileName, err)
}
return checks, nil
}
// verifyChecksums checks that every file in files (other than checksums.txt
// and checksums.txt.sig) is listed in checks with a matching sha256, that
// no file is unlisted, and that checksums.txt doesn't reference a file that
// isn't in the archive.
func verifyChecksums(files map[string][]byte, checks map[string]string) error {
for name, data := range files {
if name == checksumsFileName || name == checksumsSigFileName {
continue
}
want, ok := checks[name]
if !ok {
return fmt.Errorf("%w: %s", ErrUnlistedFile, name)
}
sum := sha256.Sum256(data)
got := hex.EncodeToString(sum[:])
if got != want {
return fmt.Errorf("%w: %s", ErrChecksumMismatch, name)
}
}
for name := range checks {
if _, ok := files[name]; !ok {
return fmt.Errorf("pkgtar: %s references missing file %s", checksumsFileName, name)
}
}
return nil
}