c53f699714
- 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>
470 lines
16 KiB
Go
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
|
|
}
|