Files
João Salavisa e145d48f22 feat: add plugin system with grpc runtime and native ui plugins (#1742)
* 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>

* fix(plugins): hydrate base app context on plugin routes

Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped
only feature flags — no workspaces, workflows, or kanban context. Native plugin
UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same
base context as the Home route for unknown SPA routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): expose TaskCreateDialog on host.ui

Lets a native plugin open kandev's real create-task modal (prefilled via
initialValues) instead of POSTing directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden filesystem/URL sinks against CodeQL findings

Add sink-level guards on the plugin trust boundary: securejoin containment on
archive extraction (zip-slip), plugin-id validation before every fs_store path,
http/https scheme validation on install-from-URL, and non-interpolated ws-handler
error logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review findings (races, resource caps, lifecycle)

- serialize per-plugin startup to close a TOCTOU double-spawn race, and let a
  plugin restart after exhausting attempts
- bound plugin startup with an explicit 30s timeout
- reject backslash paths and cap decompressed bytes by actual size in pkgtar;
  only mark a package Signed when a verifier actually ran and passed
- delete plugin_state on uninstall; validate webhook keys against the manifest
  and cap webhook body size; restart an active plugin when a new version installs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): sanitize plugin-id path sinks for CodeQL

Pin the plugin id to a single path segment with filepath.Base at recordPath/
configPath and pkgtar.Remove — a barrier the taint analysis recognizes.
Behaviour-identical for valid ids (already validated by safePluginID); clears
the go/path-injection alerts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): inline plugin-id validation at path sinks for CodeQL

filepath.Base does not strip '..', so it is not a path-injection barrier. Call
the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/
SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address code-review findings (backend)

- add capability-gating tests for the Host RPC service (was untested)
- close a stop-during-start orphan race so Disable/Uninstall can't leave a
  supervised subprocess behind
- on failed upgrade, remove only the new version dir (not all versions + data);
  restart the prior version
- validate manifest version and reject ids ending in .config (record-file alias)
- make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop
- clamp out-of-range webhook status, skip-and-warn corrupt records in List,
  write records atomically, semver-aware sideload version pick

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address code-review findings (frontend)

- wrap plugin routes + settings routes in a shared PluginErrorBoundary so a
  throwing plugin page shows a fallback instead of white-screening the SPA
- fix disable/re-enable in one session: keep the cached registration and
  re-run initialize instead of relying on a cached ES-module re-import
- prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri)
- surface partial-install warnings via toast.warning instead of a bare success
- dedupe/remove injected style links on unload; gate PluginNavItems on the flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): serialize per-plugin lifecycle + guard config writes

- add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/
  Uninstall/UpdateConfig for one id are serialized (no status-machine race)
- take s.mu in SetConfig/GetConfig and write config atomically
- bound each plugin initialize() with a timeout so one hung bundle can't stall
  the rest of the boot load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split service_test.go to satisfy file-length lint

Move the install/URL test group into service_install_test.go; no logic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: build and ship the e2e fixture plugin package to E2E shards

The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz.
Build it in the E2E build job, upload it as an artifact, and download it in both
the regular and container shard jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:00:26 +01:00

759 lines
24 KiB
Go

package pkgtar
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
)
// hostPlatformKey is the "<goos>-<goarch>" key the current test process
// runs under, matching manifest.ExecutableFor(runtime.GOOS, runtime.GOARCH).
var hostPlatformKey = runtime.GOOS + "-" + runtime.GOARCH
const managedManifestTemplate = `
id: "kandev-plugin-hello"
api_version: 1
version: %q
display_name: "Hello Plugin"
description: "A runtime-managed example plugin"
author: "kandev"
categories: ["tools"]
runtime:
type: binary
executables:
%s: "server/plugin-%s"
capabilities:
state: true
`
// managedManifestYAML returns a valid runtime-managed manifest YAML for the
// given version, declaring exactly one executable for the host platform.
func managedManifestYAML(version string) []byte {
return []byte(fmt.Sprintf(managedManifestTemplate, version, hostPlatformKey, hostPlatformKey))
}
// buildValidFiles returns the file set for a minimal, valid, multi-file
// package: manifest.yaml, the host-platform executable, and a UI asset.
func buildValidFiles(version string) map[string][]byte {
return map[string][]byte{
"manifest.yaml": managedManifestYAML(version),
"server/plugin-" + hostPlatformKey: []byte("#!/bin/sh\necho hello\n"),
"ui/bundle.js": []byte("export default {};\n"),
"assets/icon.svg": []byte("<svg></svg>"),
}
}
// writeValidPackage builds a valid signed-less package and returns its
// tar.gz bytes.
func writeValidPackage(t *testing.T, version string) []byte {
t.Helper()
var buf bytes.Buffer
if err := pkgtartest.WritePackage(&buf, buildValidFiles(version)); err != nil {
t.Fatalf("pkgtartest.WritePackage() unexpected error: %v", err)
}
return buf.Bytes()
}
func TestInstall_HappyPathMultiFileHostPlatform(t *testing.T) {
destRoot := t.TempDir()
pkg := writeValidPackage(t, "1.0.0")
result, err := Install(bytes.NewReader(pkg), destRoot)
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
wantPath := filepath.Join(destRoot, "kandev-plugin-hello", "1.0.0")
if result.InstallPath != wantPath {
t.Fatalf("InstallPath = %q, want %q", result.InstallPath, wantPath)
}
if result.Version != "1.0.0" {
t.Fatalf("Version = %q, want %q", result.Version, "1.0.0")
}
if result.Signed {
t.Fatal("Signed = true, want false (no checksums.txt.sig in package)")
}
if result.Manifest == nil || result.Manifest.ID != "kandev-plugin-hello" {
t.Fatalf("Manifest = %+v, want ID kandev-plugin-hello", result.Manifest)
}
for _, rel := range []string{"manifest.yaml", "server/plugin-" + hostPlatformKey, "ui/bundle.js", "assets/icon.svg", "checksums.txt"} {
if _, err := os.Stat(filepath.Join(wantPath, rel)); err != nil {
t.Fatalf("expected extracted file %s: %v", rel, err)
}
}
}
func TestInstall_ChmodsExecutableTo0755(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode bits are not meaningful on windows")
}
destRoot := t.TempDir()
pkg := writeValidPackage(t, "1.0.0")
result, err := Install(bytes.NewReader(pkg), destRoot)
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
execPath := filepath.Join(result.InstallPath, "server", "plugin-"+hostPlatformKey)
info, err := os.Stat(execPath)
if err != nil {
t.Fatalf("stat executable: %v", err)
}
if info.Mode().Perm() != 0o755 {
t.Fatalf("executable mode = %o, want %o", info.Mode().Perm(), 0o755)
}
// A non-executable file should not have been made executable.
assetPath := filepath.Join(result.InstallPath, "assets", "icon.svg")
assetInfo, err := os.Stat(assetPath)
if err != nil {
t.Fatalf("stat asset: %v", err)
}
if assetInfo.Mode().Perm() == 0o755 {
t.Fatalf("asset mode = %o, want an unexecuted mode (e.g. 0644)", assetInfo.Mode().Perm())
}
}
// packageWithFakeSig returns buildValidFiles(version) plus an unverifiable
// checksums.txt.sig entry (its bytes don't need to be a real signature
// unless the test also wires a VerifySignature that inspects them).
func packageWithFakeSig(version string) map[string][]byte {
files := buildValidFiles(version)
withSig := make(map[string][]byte, len(files)+1)
for name, data := range files {
withSig[name] = data
}
withSig["checksums.txt.sig"] = []byte("fake-signature-bytes")
return withSig
}
// TestInstall_SignedFalseWhenSigPresentButUnverified pins the fix for
// Install claiming Signed=true just because a checksums.txt.sig entry
// exists, even with no VerifySignature verifier configured (the default).
// Signed must only be true once a verifier actually ran and succeeded — a
// present-but-unverified signature is not a "signed" guarantee.
func TestInstall_SignedFalseWhenSigPresentButUnverified(t *testing.T) {
destRoot := t.TempDir()
pkg := buildRawPackageWithChecksums(t, packageWithFakeSig("1.0.0"))
result, err := Install(bytes.NewReader(pkg), destRoot)
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
if result.Signed {
t.Fatal("Signed = true, want false: checksums.txt.sig is present but no VerifySignature verifier is configured")
}
}
// TestInstall_SignedTrueWhenSignatureVerifies pins that Signed=true only
// once a configured VerifySignature verifier actually ran and succeeded.
func TestInstall_SignedTrueWhenSignatureVerifies(t *testing.T) {
destRoot := t.TempDir()
pkg := buildRawPackageWithChecksums(t, packageWithFakeSig("1.0.0"))
var gotSig, gotChecksums []byte
VerifySignature = func(sig, checksums []byte) error {
gotSig, gotChecksums = sig, checksums
return nil
}
t.Cleanup(func() { VerifySignature = nil })
result, err := Install(bytes.NewReader(pkg), destRoot)
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
if !result.Signed {
t.Fatal("Signed = false, want true: a configured VerifySignature verifier succeeded")
}
if string(gotSig) != "fake-signature-bytes" {
t.Fatalf("VerifySignature sig arg = %q, want the checksums.txt.sig bytes", gotSig)
}
if len(gotChecksums) == 0 {
t.Fatal("VerifySignature checksums arg was empty, want the checksums.txt bytes")
}
}
// TestInstall_ErrorsWhenSignatureVerificationFails pins that Install fails
// outright (package not extracted) when a configured VerifySignature
// verifier rejects the signature — this is a "signed but tampered/invalid"
// package, not merely "unsigned".
func TestInstall_ErrorsWhenSignatureVerificationFails(t *testing.T) {
destRoot := t.TempDir()
pkg := buildRawPackageWithChecksums(t, packageWithFakeSig("1.0.0"))
VerifySignature = func(_, _ []byte) error { return errors.New("bad signature") }
t.Cleanup(func() { VerifySignature = nil })
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error when signature verification fails, got nil")
}
assertNoPartialInstall(t, destRoot, "kandev-plugin-hello")
}
func TestInspect_ReturnsManifestWithoutSideEffects(t *testing.T) {
pkg := writeValidPackage(t, "2.3.4")
watchDir := t.TempDir()
m, err := Inspect(bytes.NewReader(pkg))
if err != nil {
t.Fatalf("Inspect() unexpected error: %v", err)
}
if m.ID != "kandev-plugin-hello" {
t.Fatalf("m.ID = %q, want %q", m.ID, "kandev-plugin-hello")
}
if m.Version != "2.3.4" {
t.Fatalf("m.Version = %q, want %q", m.Version, "2.3.4")
}
entries, err := os.ReadDir(watchDir)
if err != nil {
t.Fatalf("ReadDir() unexpected error: %v", err)
}
if len(entries) != 0 {
t.Fatalf("Inspect() wrote %d entries to an unrelated dir, want 0 (no disk side effects)", len(entries))
}
}
func TestInspect_RejectsInvalidManifest(t *testing.T) {
files := map[string][]byte{
"manifest.yaml": []byte("id: \"Bad Id!\"\napi_version: 1\nversion: \"1.0.0\"\n"),
}
var buf bytes.Buffer
if err := pkgtartest.WritePackage(&buf, files); err != nil {
t.Fatalf("pkgtartest.WritePackage() unexpected error: %v", err)
}
if _, err := Inspect(bytes.NewReader(buf.Bytes())); err == nil {
t.Fatal("Inspect() expected error for invalid manifest, got nil")
}
}
func TestInstall_BadChecksumRejected(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
pkg := buildRawPackageWithBadChecksum(t, files, "ui/bundle.js")
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for bad checksum, got nil")
}
if !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("Install() error = %v, want ErrChecksumMismatch", err)
}
assertNoPartialInstall(t, destRoot, "kandev-plugin-hello")
}
func TestInstall_MissingChecksumsRejected(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
pkg := buildRawPackage(t, files) // no checksums.txt entry
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for missing checksums.txt, got nil")
}
if !errors.Is(err, ErrMissingChecksums) {
t.Fatalf("Install() error = %v, want ErrMissingChecksums", err)
}
}
func TestInstall_UnlistedFileRejected(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
var base bytes.Buffer
baseFiles := map[string][]byte{
"manifest.yaml": files["manifest.yaml"],
"server/plugin-" + hostPlatformKey: files["server/plugin-"+hostPlatformKey],
}
if err := pkgtartest.WritePackage(&base, baseFiles); err != nil {
t.Fatalf("pkgtartest.WritePackage() unexpected error: %v", err)
}
extracted := extractAll(t, base.Bytes())
// Add a file that is NOT covered by checksums.txt.
withExtra := map[string][]byte{
"manifest.yaml": files["manifest.yaml"],
"server/plugin-" + hostPlatformKey: files["server/plugin-"+hostPlatformKey],
"ui/bundle.js": files["ui/bundle.js"], // unlisted
"checksums.txt": extracted["checksums.txt"],
}
pkg := buildRawPackage(t, withExtra)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for unlisted file, got nil")
}
if !errors.Is(err, ErrUnlistedFile) {
t.Fatalf("Install() error = %v, want ErrUnlistedFile", err)
}
}
func TestInstall_TraversalPathRejected(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
files["../evil.txt"] = []byte("pwned")
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for path traversal entry, got nil")
}
if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("Install() error = %v, want ErrPathTraversal", err)
}
}
func TestInstall_AbsolutePathRejected(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
files["/etc/passwd"] = []byte("pwned")
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for absolute path entry, got nil")
}
if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("Install() error = %v, want ErrPathTraversal", err)
}
}
// TestInstall_BackslashTraversalRejected pins the fix for a Windows-style
// traversal entry: cleanArchivePath previously only treated "/" as a path
// separator, so "server/..\\..\\escape.exe" passed path.Clean unchanged (it
// has no leading ".." segment under POSIX splitting) and would later be
// interpreted by filepath.Join/filepath.Clean on Windows as an actual
// escape via the backslash-delimited "..\\.." segments.
func TestInstall_BackslashTraversalRejected(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
files[`server/..\..\escape.exe`] = []byte("pwned")
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for a backslash-traversal entry, got nil")
}
if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("Install() error = %v, want ErrPathTraversal", err)
}
}
func TestCleanArchivePath_RejectsBackslash(t *testing.T) {
for _, name := range []string{
`server\evil.exe`,
`server/..\..\escape.exe`,
`..\evil.exe`,
} {
if _, err := cleanArchivePath(name); err == nil {
t.Fatalf("cleanArchivePath(%q) expected error, got nil", name)
} else if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("cleanArchivePath(%q) error = %v, want ErrPathTraversal", name, err)
}
}
}
// withSmallSizeCaps temporarily overrides maxPackageFileSize/
// maxPackageTotalSize (test-only vars, see pkgtar.go) so cap-enforcement
// tests can use small fixtures instead of allocating real 200MB payloads.
func withSmallSizeCaps(t *testing.T, perFile, total int64) {
t.Helper()
origFile, origTotal := maxPackageFileSize, maxPackageTotalSize
maxPackageFileSize, maxPackageTotalSize = perFile, total
t.Cleanup(func() { maxPackageFileSize, maxPackageTotalSize = origFile, origTotal })
}
func TestInstall_SingleFileExceedingPerFileCapRejected(t *testing.T) {
withSmallSizeCaps(t, 100, 10_000)
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
files["assets/icon.svg"] = bytes.Repeat([]byte("A"), 200) // exceeds the 100-byte per-file cap
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for a file exceeding the per-file size cap, got nil")
}
}
// TestReadArchive_TotalSizeCapUsesActualBytesCopied pins the fix that the
// running total is accumulated from bytes actually copied out of the
// decompressed stream (readCapped's return value), not the archive-declared
// hdr.Size: three files that individually fit under the per-file cap but
// whose combined ACTUAL bytes exceed the total cap must still be rejected.
func TestReadArchive_TotalSizeCapUsesActualBytesCopied(t *testing.T) {
withSmallSizeCaps(t, 1000, 2000)
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
files["assets/a"] = bytes.Repeat([]byte("A"), 900)
files["assets/b"] = bytes.Repeat([]byte("B"), 900)
files["assets/c"] = bytes.Repeat([]byte("C"), 900) // pushes the real total past the 2000-byte cap
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error once actual bytes copied exceed the total size cap, got nil")
}
}
func TestReadArchive_UnderTotalCapSucceeds(t *testing.T) {
withSmallSizeCaps(t, 1000, 100_000)
destRoot := t.TempDir()
pkg := writeValidPackage(t, "1.0.0") // well under 100_000 bytes total
if _, err := Install(bytes.NewReader(pkg), destRoot); err != nil {
t.Fatalf("Install() unexpected error under the total size cap: %v", err)
}
}
func TestInstall_SymlinkEntryRejected(t *testing.T) {
destRoot := t.TempDir()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
files := buildValidFiles("1.0.0")
for name, data := range files {
writeRawEntry(t, tw, name, data, tar.TypeReg)
}
writeRawEntry(t, tw, "server/evil-link", nil, tar.TypeSymlink)
checksums := checksumsFor(files)
writeRawEntry(t, tw, "checksums.txt", checksums, tar.TypeReg)
if err := tw.Close(); err != nil {
t.Fatalf("tar Close() error: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gzip Close() error: %v", err)
}
_, err := Install(bytes.NewReader(buf.Bytes()), destRoot)
if err == nil {
t.Fatal("Install() expected error for symlink entry, got nil")
}
}
func TestInstall_MissingHostPlatformKeyRejected(t *testing.T) {
destRoot := t.TempDir()
otherPlatform := "plan9-arm"
manifestYAML := []byte(fmt.Sprintf(managedManifestTemplate, "1.0.0", otherPlatform, otherPlatform))
files := map[string][]byte{
"manifest.yaml": manifestYAML,
"server/plugin-" + otherPlatform: []byte("binary"),
}
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for missing host platform key, got nil")
}
if !errors.Is(err, ErrPlatformNotSupported) {
t.Fatalf("Install() error = %v, want ErrPlatformNotSupported", err)
}
}
func TestInstall_DuplicateVersionRejected(t *testing.T) {
destRoot := t.TempDir()
pkg := writeValidPackage(t, "1.0.0")
if _, err := Install(bytes.NewReader(pkg), destRoot); err != nil {
t.Fatalf("first Install() unexpected error: %v", err)
}
pkg2 := writeValidPackage(t, "1.0.0")
_, err := Install(bytes.NewReader(pkg2), destRoot)
if err == nil {
t.Fatal("second Install() expected ErrVersionExists, got nil")
}
if !errors.Is(err, ErrVersionExists) {
t.Fatalf("Install() error = %v, want ErrVersionExists", err)
}
}
func TestInstall_InvalidManifestRejected(t *testing.T) {
destRoot := t.TempDir()
files := map[string][]byte{
"manifest.yaml": []byte("id: \"Bad Id!\"\napi_version: 1\nversion: \"1.0.0\"\n"),
}
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for invalid manifest, got nil")
}
if !errors.Is(err, ErrManifestInvalid) {
t.Fatalf("Install() error = %v, want ErrManifestInvalid", err)
}
}
func TestInstall_LegacyRemoteManifestRejected(t *testing.T) {
destRoot := t.TempDir()
legacyManifest := []byte(`
id: "kandev-plugin-legacy"
api_version: 1
version: "1.0.0"
display_name: "Legacy"
description: "legacy remote plugin"
author: "kandev"
categories: ["tools"]
base_url: "http://localhost:9100"
endpoints:
health: "/health"
events: "/events"
tools: "/tools/{tool_name}"
webhooks: "/webhooks/{webhook_key}"
`)
files := map[string][]byte{"manifest.yaml": legacyManifest}
pkg := buildRawPackageWithChecksums(t, files)
_, err := Install(bytes.NewReader(pkg), destRoot)
if err == nil {
t.Fatal("Install() expected error for legacy-remote (non-managed) manifest, got nil")
}
if !errors.Is(err, ErrManifestInvalid) {
t.Fatalf("Install() error = %v, want ErrManifestInvalid", err)
}
}
func TestInstall_AtomicNoPartialDirOnFailure(t *testing.T) {
destRoot := t.TempDir()
files := buildValidFiles("1.0.0")
pkg := buildRawPackageWithBadChecksum(t, files, "manifest.yaml")
if _, err := Install(bytes.NewReader(pkg), destRoot); err == nil {
t.Fatal("Install() expected error, got nil")
}
assertNoPartialInstall(t, destRoot, "kandev-plugin-hello")
}
func TestRemove_DeletesAllVersionsAndData(t *testing.T) {
destRoot := t.TempDir()
pkg1 := writeValidPackage(t, "1.0.0")
if _, err := Install(bytes.NewReader(pkg1), destRoot); err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
pkg2 := writeValidPackage(t, "2.0.0")
if _, err := Install(bytes.NewReader(pkg2), destRoot); err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
pluginDir := filepath.Join(destRoot, "kandev-plugin-hello")
dataDir := filepath.Join(pluginDir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("MkdirAll(data) error: %v", err)
}
if err := Remove(destRoot, "kandev-plugin-hello"); err != nil {
t.Fatalf("Remove() unexpected error: %v", err)
}
if _, err := os.Stat(pluginDir); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("plugin dir still exists after Remove(): err = %v", err)
}
}
func TestRemove_RejectsUnsafeID(t *testing.T) {
destRoot := t.TempDir()
if err := Remove(destRoot, "../escape"); err == nil {
t.Fatal("Remove() expected error for unsafe id, got nil")
}
}
// --- test helpers: raw archive construction for negative-path scenarios ---
func buildRawPackage(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, data := range files {
writeRawEntry(t, tw, name, data, tar.TypeReg)
}
if err := tw.Close(); err != nil {
t.Fatalf("tar Close() error: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gzip Close() error: %v", err)
}
return buf.Bytes()
}
func buildRawPackageWithChecksums(t *testing.T, files map[string][]byte) []byte {
t.Helper()
withChecksums := make(map[string][]byte, len(files)+1)
for name, data := range files {
withChecksums[name] = data
}
withChecksums["checksums.txt"] = checksumsFor(files)
return buildRawPackage(t, withChecksums)
}
func buildRawPackageWithBadChecksum(t *testing.T, files map[string][]byte, corrupt string) []byte {
t.Helper()
checksums := checksumsFor(files)
// Flip a character in the recorded hash for `corrupt` so verification fails.
lines := strings.Split(string(checksums), "\n")
for i, line := range lines {
if strings.HasSuffix(line, " "+corrupt) {
lines[i] = "0000000000000000000000000000000000000000000000000000000000000000 " + corrupt
}
}
withChecksums := make(map[string][]byte, len(files)+1)
for name, data := range files {
withChecksums[name] = data
}
withChecksums["checksums.txt"] = []byte(strings.Join(lines, "\n"))
return buildRawPackage(t, withChecksums)
}
// checksumsFor computes "sha256 path" lines for every entry in files,
// excluding checksums.txt and checksums.txt.sig themselves (per the
// package format: checksums.txt lists every OTHER file).
func checksumsFor(files map[string][]byte) []byte {
var buf bytes.Buffer
for name, data := range files {
if name == "checksums.txt" || name == "checksums.txt.sig" {
continue
}
sum := sha256.Sum256(data)
fmt.Fprintf(&buf, "%s %s\n", hex.EncodeToString(sum[:]), name)
}
return buf.Bytes()
}
func writeRawEntry(t *testing.T, tw *tar.Writer, name string, data []byte, typeflag byte) {
t.Helper()
hdr := &tar.Header{
Name: name,
Typeflag: typeflag,
Mode: 0o644,
Size: int64(len(data)),
}
if typeflag == tar.TypeSymlink {
hdr.Linkname = "/etc/passwd"
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader(%s) error: %v", name, err)
}
if len(data) > 0 {
if _, err := tw.Write(data); err != nil {
t.Fatalf("Write(%s) error: %v", name, err)
}
}
}
// extractAll un-gzips and un-tars pkg into a name->data map, for tests that
// need to inspect or reuse a generated checksums.txt.
func extractAll(t *testing.T, pkg []byte) map[string][]byte {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(pkg))
if err != nil {
t.Fatalf("gzip.NewReader() error: %v", err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
out := make(map[string][]byte)
for {
hdr, err := tr.Next()
if err != nil {
break
}
if hdr.Typeflag != tar.TypeReg {
continue
}
data, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("reading tar entry %s: %v", hdr.Name, err)
}
out[hdr.Name] = data
}
return out
}
func assertNoPartialInstall(t *testing.T, destRoot, id string) {
t.Helper()
pluginDir := filepath.Join(destRoot, id)
entries, err := os.ReadDir(pluginDir)
if errors.Is(err, fs.ErrNotExist) {
return
}
if err != nil {
t.Fatalf("ReadDir(%s) error: %v", pluginDir, err)
}
for _, e := range entries {
if !strings.HasPrefix(e.Name(), ".tmp-") {
t.Fatalf("found non-tmp entry %q after failed Install(), want no partial version dir", e.Name())
}
}
}
func TestSecureJoin_RejectsTraversalRelativePath(t *testing.T) {
root := t.TempDir()
_, err := securejoin(root, "../evil.txt")
if err == nil {
t.Fatal("securejoin() expected error for traversal path, got nil")
}
if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("securejoin() error = %v, want ErrPathTraversal", err)
}
}
func TestSecureJoin_RejectsDeeplyNestedTraversal(t *testing.T) {
root := t.TempDir()
_, err := securejoin(root, "server/../../evil.txt")
if err == nil {
t.Fatal("securejoin() expected error for nested traversal path, got nil")
}
if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("securejoin() error = %v, want ErrPathTraversal", err)
}
}
func TestSecureJoin_RejectsAbsolutePath(t *testing.T) {
root := t.TempDir()
_, err := securejoin(root, "/etc/passwd")
if err == nil {
t.Fatal("securejoin() expected error for absolute path, got nil")
}
if !errors.Is(err, ErrPathTraversal) {
t.Fatalf("securejoin() error = %v, want ErrPathTraversal", err)
}
}
func TestSecureJoin_AllowsCleanRelativePath(t *testing.T) {
root := t.TempDir()
got, err := securejoin(root, filepath.Join("server", "plugin-linux-amd64"))
if err != nil {
t.Fatalf("securejoin() unexpected error: %v", err)
}
want := filepath.Join(root, "server", "plugin-linux-amd64")
if got != want {
t.Fatalf("securejoin() = %q, want %q", got, want)
}
}