fix(plugins): package binary installs as zip archives and self-repair legacy raw-binary rows

Binary catalog install/update paths (installBinary/replaceBinary) saved raw
plugin binaries into plugin_archives.archive_bytes, but startup preload opens
that column with zip.NewReader expecting a package containing manifest.json
and the plugin binary. Affected installs failed preload after restart with
"zip: not a valid zip file".

- Package manifest.json + binary into a validated zip before SaveArchive on
  both binary install paths.
- Self-repair legacy rows in ArchiveCache.Ensure: when stored bytes are not a
  zip but hash-match both the stored checksum and the stored manifest's
  checksum, repackage and persist the repaired archive. A persist failure is
  logged, not fatal — the in-memory archive is already valid and recovery
  retries on the next preload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-06 08:13:50 -04:00
co-authored by Claude Fable 5
parent ba2bac7f55
commit e3a209c4ff
4 changed files with 313 additions and 3 deletions
+86 -1
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"os"
"path/filepath"
@@ -15,6 +16,7 @@ import (
type archiveStore interface {
GetArchive(ctx context.Context, installationID int) (*InstallationArchive, error)
SaveArchive(ctx context.Context, installationID int, manifestJSON []byte, checksum string, archiveBytes []byte) error
}
type ArchiveCache struct {
@@ -46,7 +48,10 @@ func (c *ArchiveCache) Ensure(ctx context.Context, installation *Installation) (
reader, manifestBytes, manifest, err := openPluginArchive(archive.Bytes)
if err != nil {
return nil, fmt.Errorf("open stored plugin archive for installation %d: %w", installation.ID, err)
reader, manifestBytes, manifest, err = c.recoverLegacyBinaryArchive(ctx, installation.ID, archive, err)
if err != nil {
return nil, fmt.Errorf("open stored plugin archive for installation %d: %w", installation.ID, err)
}
}
if archive.Checksum != manifest.GetChecksum() {
return nil, fmt.Errorf("stored plugin archive checksum mismatch for installation %d", installation.ID)
@@ -88,6 +93,51 @@ func (c *ArchiveCache) Ensure(ctx context.Context, installation *Installation) (
return manifest, nil
}
func (c *ArchiveCache) recoverLegacyBinaryArchive(
ctx context.Context,
installationID int,
archive *InstallationArchive,
openErr error,
) (*zip.Reader, []byte, *pluginv1.PluginManifest, error) {
if archive == nil || len(archive.ManifestJSON) == 0 || len(archive.Bytes) == 0 {
return nil, nil, nil, openErr
}
manifest, err := LoadManifestBytes(archive.ManifestJSON)
if err != nil {
return nil, nil, nil, openErr
}
checksum := sha256.Sum256(archive.Bytes)
actualChecksum := hex.EncodeToString(checksum[:])
if actualChecksum != archive.Checksum || actualChecksum != manifest.GetChecksum() {
return nil, nil, nil, openErr
}
archiveBytes, err := buildBinaryPluginArchive(archive.ManifestJSON, archive.Bytes)
if err != nil {
return nil, nil, nil, fmt.Errorf("%w; recover legacy raw binary archive: %v", openErr, err)
}
reader, manifestBytes, manifest, err := openPluginArchive(archiveBytes)
if err != nil {
return nil, nil, nil, fmt.Errorf("%w; recover legacy raw binary archive: %v", openErr, err)
}
// Persist the repaired archive so future preloads skip recovery, but don't
// fail startup over a write error — the in-memory archive is already valid
// and recovery will retry on the next preload.
if err := c.archives.SaveArchive(ctx, installationID, manifestBytes, manifest.GetChecksum(), archiveBytes); err != nil {
slog.Warn(
"failed to persist recovered legacy plugin archive; will retry on next preload",
"installation_id", installationID,
"error", err,
)
}
return reader, manifestBytes, manifest, nil
}
func openPluginArchive(data []byte) (*zip.Reader, []byte, *pluginv1.PluginManifest, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
@@ -135,6 +185,41 @@ func openPluginArchive(data []byte) (*zip.Reader, []byte, *pluginv1.PluginManife
return reader, manifestBytes, manifest, nil
}
func buildBinaryPluginArchive(manifestBytes []byte, binaryData []byte) ([]byte, error) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
if err := writeArchiveEntry(writer, "manifest.json", manifestBytes); err != nil {
_ = writer.Close()
return nil, err
}
if err := writeArchiveEntry(writer, "plugin", binaryData); err != nil {
_ = writer.Close()
return nil, err
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close plugin archive: %w", err)
}
archiveBytes := buffer.Bytes()
if _, _, _, err := openPluginArchive(archiveBytes); err != nil {
return nil, fmt.Errorf("validate plugin archive: %w", err)
}
return archiveBytes, nil
}
func writeArchiveEntry(writer *zip.Writer, name string, data []byte) error {
entry, err := writer.Create(name)
if err != nil {
return fmt.Errorf("create plugin archive entry %q: %w", name, err)
}
if _, err := entry.Write(data); err != nil {
return fmt.Errorf("write plugin archive entry %q: %w", name, err)
}
return nil
}
func extractArchiveFiles(reader *zip.Reader, root string) error {
for _, file := range reader.File {
if err := extractZipFile(file, root); err != nil {
+150
View File
@@ -0,0 +1,150 @@
package plugins
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"testing"
"google.golang.org/protobuf/encoding/protojson"
)
func TestArchiveCacheEnsureRecoversLegacyBinaryArchive(t *testing.T) {
ctx := context.Background()
binaryData := []byte("#!/bin/sh\nexit 0\n")
checksum := sha256.Sum256(binaryData)
manifest := testPluginManifest(t, "silo.metadb", "0.0.19")
manifest.Checksum = hex.EncodeToString(checksum[:])
manifestBytes, err := protojson.Marshal(manifest)
if err != nil {
t.Fatalf("protojson.Marshal() returned error: %v", err)
}
store := &legacyArchiveStore{
archive: &InstallationArchive{
InstallationID: 42,
ManifestJSON: manifestBytes,
Checksum: manifest.GetChecksum(),
Bytes: binaryData,
},
}
installDir := filepath.Join(t.TempDir(), "plugins", "silo.metadb", "0.0.19")
installation := &Installation{
ID: 42,
PluginID: manifest.GetPluginId(),
Version: manifest.GetVersion(),
InstallPath: filepath.Join(installDir, "plugin"),
}
got, err := NewArchiveCache(store).Ensure(ctx, installation)
if err != nil {
t.Fatalf("Ensure() returned error: %v", err)
}
if got.GetPluginId() != manifest.GetPluginId() {
t.Fatalf("manifest plugin_id = %q, want %q", got.GetPluginId(), manifest.GetPluginId())
}
if store.savedInstallationID != installation.ID {
t.Fatalf("repaired archive installation id = %d, want %d", store.savedInstallationID, installation.ID)
}
if !bytes.Equal(store.savedManifestJSON, manifestBytes) {
t.Fatal("repaired archive manifest does not match stored manifest")
}
if _, _, savedManifest, err := openPluginArchive(store.savedArchiveBytes); err != nil {
t.Fatalf("repaired archive is not a valid plugin archive: %v", err)
} else if savedManifest.GetChecksum() != manifest.GetChecksum() {
t.Fatalf("repaired archive checksum = %q, want %q", savedManifest.GetChecksum(), manifest.GetChecksum())
}
installedBinary, err := os.ReadFile(installation.InstallPath)
if err != nil {
t.Fatalf("read rehydrated plugin binary: %v", err)
}
if !bytes.Equal(installedBinary, binaryData) {
t.Fatal("rehydrated plugin binary does not match legacy binary bytes")
}
if _, err := os.Stat(InstalledManifestPath(installation.InstallPath)); err != nil {
t.Fatalf("expected rehydrated manifest file: %v", err)
}
}
func TestArchiveCacheEnsureRecoveryToleratesPersistFailure(t *testing.T) {
ctx := context.Background()
binaryData := []byte("#!/bin/sh\nexit 0\n")
checksum := sha256.Sum256(binaryData)
manifest := testPluginManifest(t, "silo.metadb", "0.0.19")
manifest.Checksum = hex.EncodeToString(checksum[:])
manifestBytes, err := protojson.Marshal(manifest)
if err != nil {
t.Fatalf("protojson.Marshal() returned error: %v", err)
}
store := &legacyArchiveStore{
archive: &InstallationArchive{
InstallationID: 42,
ManifestJSON: manifestBytes,
Checksum: manifest.GetChecksum(),
Bytes: binaryData,
},
saveErr: errors.New("db unavailable"),
}
installDir := filepath.Join(t.TempDir(), "plugins", "silo.metadb", "0.0.19")
installation := &Installation{
ID: 42,
PluginID: manifest.GetPluginId(),
Version: manifest.GetVersion(),
InstallPath: filepath.Join(installDir, "plugin"),
}
if _, err := NewArchiveCache(store).Ensure(ctx, installation); err != nil {
t.Fatalf("Ensure() returned error despite in-memory recovery: %v", err)
}
installedBinary, err := os.ReadFile(installation.InstallPath)
if err != nil {
t.Fatalf("read rehydrated plugin binary: %v", err)
}
if !bytes.Equal(installedBinary, binaryData) {
t.Fatal("rehydrated plugin binary does not match legacy binary bytes")
}
}
type legacyArchiveStore struct {
archive *InstallationArchive
saveErr error
savedInstallationID int
savedManifestJSON []byte
savedChecksum string
savedArchiveBytes []byte
}
func (s *legacyArchiveStore) GetArchive(context.Context, int) (*InstallationArchive, error) {
return s.archive, nil
}
func (s *legacyArchiveStore) SaveArchive(
_ context.Context,
installationID int,
manifestJSON []byte,
checksum string,
archiveBytes []byte,
) error {
if s.saveErr != nil {
return s.saveErr
}
s.savedInstallationID = installationID
s.savedManifestJSON = append([]byte(nil), manifestJSON...)
s.savedChecksum = checksum
s.savedArchiveBytes = append([]byte(nil), archiveBytes...)
return nil
}
+10 -2
View File
@@ -256,6 +256,10 @@ func (i *Installer) replaceBinary(
if err != nil {
return nil, fmt.Errorf("serialize plugin manifest: %w", err)
}
archiveBytes, err := buildBinaryPluginArchive(manifestBytes, binaryData)
if err != nil {
return nil, fmt.Errorf("package plugin archive: %w", err)
}
installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion()))
if err := os.MkdirAll(installRoot, 0755); err != nil {
@@ -289,7 +293,7 @@ func (i *Installer) replaceBinary(
return nil, err
}
if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, checksum, binaryData); err != nil {
if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, checksum, archiveBytes); err != nil {
return nil, fmt.Errorf("persist replacement plugin archive: %w", err)
}
@@ -412,6 +416,10 @@ func (i *Installer) installBinary(ctx context.Context, binaryData []byte, checks
if err != nil {
return nil, fmt.Errorf("serialize plugin manifest: %w", err)
}
archiveBytes, err := buildBinaryPluginArchive(manifestBytes, binaryData)
if err != nil {
return nil, fmt.Errorf("package plugin archive: %w", err)
}
installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion()))
if err := os.MkdirAll(installRoot, 0755); err != nil {
@@ -461,7 +469,7 @@ func (i *Installer) installBinary(ctx context.Context, binaryData []byte, checks
return nil, fmt.Errorf("persist plugin installation: %w", err)
}
if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, checksum, binaryData); err != nil {
if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, checksum, archiveBytes); err != nil {
if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil {
return nil, fmt.Errorf("persist plugin archive: %w (cleanup failed: %v)", err, deleteErr)
}
@@ -1,6 +1,8 @@
package plugins
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -13,6 +15,31 @@ import (
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
func TestInstallerInstallBinaryPersistsPackagedArchive(t *testing.T) {
ctx := context.Background()
store := newRecordingInstallationStore()
installer := NewInstaller(store, InstallerOptions{BaseDir: t.TempDir()})
manifest := testPluginManifest(t, "silo.metadb", "0.0.19")
binaryData := []byte("#!/bin/sh\nexit 0\n")
checksum := sha256.Sum256(binaryData)
manifest.Checksum = hex.EncodeToString(checksum[:])
result, err := installer.installBinary(ctx, binaryData, manifest.GetChecksum(), manifest, nil)
if err != nil {
t.Fatalf("installBinary() returned error: %v", err)
}
if result.Installation.ID != 1 {
t.Fatalf("result installation id = %d, want 1", result.Installation.ID)
}
if len(store.saveArchiveIDs) != 1 || store.saveArchiveIDs[0] != 1 {
t.Fatalf("SaveArchive() ids = %#v, want [1]", store.saveArchiveIDs)
}
assertStoredPluginArchive(t, store.saveArchiveBytes, store.saveArchiveManifest, binaryData)
}
func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) {
ctx := context.Background()
@@ -28,6 +55,7 @@ func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) {
manifest := testPluginManifest(t, "silo.metadb", "0.0.19")
binaryData := []byte("#!/bin/sh\nexit 0\n")
checksum := sha256.Sum256(binaryData)
manifest.Checksum = hex.EncodeToString(checksum[:])
result, err := installer.replaceBinary(ctx, &Installation{
ID: 15,
@@ -46,6 +74,7 @@ func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) {
if len(store.saveArchiveIDs) != 1 || store.saveArchiveIDs[0] != 15 {
t.Fatalf("SaveArchive() ids = %#v, want [15]", store.saveArchiveIDs)
}
assertStoredPluginArchive(t, store.saveArchiveBytes, store.saveArchiveManifest, binaryData)
if len(store.updateIDs) != 1 || store.updateIDs[0] != 15 {
t.Fatalf("Update() ids = %#v, want [15]", store.updateIDs)
}
@@ -86,6 +115,44 @@ func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) {
}
}
func assertStoredPluginArchive(t *testing.T, archiveBytes, manifestBytes, binaryData []byte) {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes)))
if err != nil {
t.Fatalf("stored archive is not a zip: %v", err)
}
files := make(map[string]*zip.File, len(reader.File))
for _, file := range reader.File {
files[file.Name] = file
}
manifestFile, ok := files["manifest.json"]
if !ok {
t.Fatal("stored archive is missing manifest.json")
}
gotManifest, err := readZipFile(manifestFile)
if err != nil {
t.Fatalf("read manifest.json from stored archive: %v", err)
}
if !bytes.Equal(gotManifest, manifestBytes) {
t.Fatal("stored archive manifest does not match saved manifest bytes")
}
binaryFile, ok := files["plugin"]
if !ok {
t.Fatal("stored archive is missing plugin")
}
gotBinary, err := readZipFile(binaryFile)
if err != nil {
t.Fatalf("read plugin from stored archive: %v", err)
}
if !bytes.Equal(gotBinary, binaryData) {
t.Fatal("stored archive binary does not match installed binary")
}
}
func testPluginManifest(t *testing.T, pluginID, version string) *pluginv1.PluginManifest {
t.Helper()