Files
kandev/apps/backend/internal/plugins/pkgtar/pkgtar.go
T
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

549 lines
20 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"
"regexp"
"runtime"
"strings"
"github.com/kandev/kandev/internal/plugins/manifest"
)
// pluginIDPattern mirrors the manifest id rule: a single clean path segment
// of lowercase alphanumerics, dots, underscores, and hyphens. Used as an
// inline barrier before any id is joined into a filesystem path.
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
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, and
// maxPackageTotalSize caps the sum of all files' ACTUAL (decompressed)
// bytes copied during Install — see readArchive. Declared as vars (not
// consts), mirroring VerifySignature below, purely so this package's own
// tests can override them to exercise the cap logic against small fixtures
// instead of allocating real 200MB payloads; production defaults are
// unchanged.
var (
maxPackageFileSize int64 = 200 << 20 // 200 MiB
maxPackageTotalSize int64 = 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 not performed: a present checksums.txt.sig
// entry with no VerifySignature configured leaves InstallResult.Signed
// false (unverified, not merely "present") — Install still succeeds since
// signing is optional. Set this (e.g. to an ed25519 verifier) to enable
// real verification; a non-nil error from it fails Install outright, and a
// nil error sets InstallResult.Signed=true.
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 AND a configured VerifySignature verifier successfully
// verified it. A present-but-unverified signature (no verifier wired)
// or a missing signature both leave this false. See VerifySignature.
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) runs
// checksums.txt.sig through VerifySignature. signed is true only when a
// VerifySignature verifier is configured AND it actually succeeded — a
// present-but-unverified signature (no verifier wired, the default) is
// reported as unsigned rather than claiming a guarantee nothing checked. A
// present signature that fails verification fails Install outright (a
// tampered/invalid signature is worse than no signature at all); signing
// otherwise remains optional and never blocks an unsigned install.
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 || VerifySignature == nil {
return false, nil
}
if err := VerifySignature(sigData, checksumsData); err != nil {
return false, 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, err := securejoin(destRoot, m.ID)
if err != nil {
return "", err
}
versionDir, err := securejoin(pluginDir, m.Version)
if err != nil {
return "", err
}
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. Every entry name is archive-controlled, so each destination path
// is re-validated with securejoin at this write sink even though
// cleanArchivePath already rejected unsafe names when the archive was
// read (defense in depth: this is the actual filesystem write).
func writePackageFiles(root string, files map[string][]byte, execSet map[string]bool) error {
for name, data := range files {
dest, err := securejoin(root, filepath.FromSlash(name))
if err != nil {
return err
}
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
}
// securejoin joins root and rel (a path derived from archive-controlled
// input: an archive entry name or a manifest-declared executable path)
// and verifies the result stays within root. It is the sink-level guard
// against path traversal / "zip slip": callers upstream (cleanArchivePath,
// manifest.Validate) already reject unsafe names, but this re-checks
// containment right before the filesystem operation that uses the path.
func securejoin(root, rel string) (string, error) {
if filepath.IsAbs(rel) {
return "", fmt.Errorf("%w: absolute path %q", ErrPathTraversal, rel)
}
dst := filepath.Join(root, rel)
cleanRoot := filepath.Clean(root)
relToRoot, err := filepath.Rel(cleanRoot, filepath.Clean(dst))
if err != nil {
return "", fmt.Errorf("%w: %s", ErrPathTraversal, rel)
}
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
return "", fmt.Errorf("%w: %s", ErrPathTraversal, rel)
}
return dst, 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 !pluginIDPattern.MatchString(id) {
return fmt.Errorf("pkgtar: invalid plugin id %q", id)
}
dir, err := securejoin(destRoot, id)
if err != nil {
return err
}
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 "..").
//
// Both caps are enforced against bytes ACTUALLY copied out of the
// decompressed stream, not the archive-declared hdr.Size: hdr.Size comes
// from the (attacker-controlled) tar header, and a gzip stream can be
// crafted to decompress to far more data than a small compressed payload
// suggests. Every entry's read is independently bounded by
// io.LimitReader inside readCapped regardless of what hdr.Size claims, and
// the running `total` is accumulated from len(data) — the real byte count
// — after that bounded read, so a mismatched/lied Size cannot smuggle more
// decompressed data past either cap.
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 {
// Fast pre-check: reject an implausible declared size before
// even attempting the (still independently bounded) read.
return nil, fmt.Errorf("pkgtar: file %s exceeds max size of %d bytes", name, maxPackageFileSize)
}
data, err := readCapped(tr, maxPackageFileSize)
if err != nil {
return nil, fmt.Errorf("pkgtar: reading %s: %w", name, err)
}
total += int64(len(data))
if total > maxPackageTotalSize {
return nil, fmt.Errorf("pkgtar: package exceeds max total size of %d bytes", maxPackageTotalSize)
}
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, contain a backslash, or escape the package root via
// "..". Backslashes are rejected outright rather than treated as a path
// separator: path.Clean/path.IsAbs only understand "/", so an entry like
// "server/..\\..\\escape.exe" would pass through unchanged (no leading ".."
// segment under "/"-only splitting) and could then be reinterpreted as an
// actual traversal by filepath.Join/filepath.Clean on a host where "\\" is
// a separator (Windows) — securejoin (the write-sink guard) runs
// filepath.Join too, so the same risk applies there.
func cleanArchivePath(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("%w: empty entry name", ErrPathTraversal)
}
if strings.ContainsRune(name, '\\') {
return "", fmt.Errorf("%w: backslash in entry name %q", ErrPathTraversal, name)
}
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
}