2026-05-22 20:26:11 -04:00
|
|
|
package plugins
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"archive/zip"
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
2026-07-06 08:13:50 -04:00
|
|
|
"log/slog"
|
2026-05-22 20:26:11 -04:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type archiveStore interface {
|
|
|
|
|
GetArchive(ctx context.Context, installationID int) (*InstallationArchive, error)
|
2026-07-06 08:13:50 -04:00
|
|
|
SaveArchive(ctx context.Context, installationID int, manifestJSON []byte, checksum string, archiveBytes []byte) error
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ArchiveCache struct {
|
|
|
|
|
archives archiveStore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewArchiveCache(archives archiveStore) *ArchiveCache {
|
|
|
|
|
if archives == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return &ArchiveCache{archives: archives}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *ArchiveCache) Ensure(ctx context.Context, installation *Installation) (*pluginv1.PluginManifest, error) {
|
|
|
|
|
if installation == nil {
|
|
|
|
|
return nil, fmt.Errorf("plugin installation is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if manifest, err := LoadManifestFile(InstalledManifestPath(installation.InstallPath)); err == nil {
|
|
|
|
|
if err := installedFilesPresent(installation.InstallPath, manifest); err == nil {
|
|
|
|
|
return manifest, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
archive, err := c.archives.GetArchive(ctx, installation.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("load stored plugin archive for installation %d: %w", installation.ID, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reader, manifestBytes, manifest, err := openPluginArchive(archive.Bytes)
|
|
|
|
|
if err != nil {
|
2026-07-06 08:13:50 -04:00
|
|
|
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)
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
if archive.Checksum != manifest.GetChecksum() {
|
|
|
|
|
return nil, fmt.Errorf("stored plugin archive checksum mismatch for installation %d", installation.ID)
|
|
|
|
|
}
|
|
|
|
|
if len(archive.ManifestJSON) > 0 && !bytes.Equal(archive.ManifestJSON, manifestBytes) {
|
|
|
|
|
return nil, fmt.Errorf("stored plugin manifest mismatch for installation %d", installation.ID)
|
|
|
|
|
}
|
|
|
|
|
if installation.PluginID != "" && manifest.GetPluginId() != installation.PluginID {
|
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
|
"stored plugin archive plugin_id %q does not match installation %q",
|
|
|
|
|
manifest.GetPluginId(),
|
|
|
|
|
installation.PluginID,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if installation.Version != "" && manifest.GetVersion() != installation.Version {
|
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
|
"stored plugin archive version %q does not match installation %q",
|
|
|
|
|
manifest.GetVersion(),
|
|
|
|
|
installation.Version,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
installDir := filepath.Dir(installation.InstallPath)
|
|
|
|
|
if err := os.RemoveAll(installDir); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("clear plugin cache dir %q: %w", installDir, err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(installDir, 0755); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create plugin cache dir %q: %w", installDir, err)
|
|
|
|
|
}
|
|
|
|
|
if err := extractArchiveFiles(reader, installDir); err != nil {
|
|
|
|
|
_ = os.RemoveAll(installDir)
|
|
|
|
|
return nil, fmt.Errorf("extract stored plugin archive for installation %d: %w", installation.ID, err)
|
|
|
|
|
}
|
|
|
|
|
if err := validateInstalledFiles(installation.InstallPath, manifest); err != nil {
|
|
|
|
|
_ = os.RemoveAll(installDir)
|
|
|
|
|
return nil, fmt.Errorf("validate rehydrated plugin cache for installation %d: %w", installation.ID, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return manifest, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 08:13:50 -04:00
|
|
|
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 {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.WarnContext(
|
|
|
|
|
ctx,
|
2026-07-06 08:13:50 -04:00
|
|
|
"failed to persist recovered legacy plugin archive; will retry on next preload",
|
2026-07-09 20:53:52 +08:00
|
|
|
"component", "plugins",
|
2026-07-06 08:13:50 -04:00
|
|
|
"installation_id", installationID,
|
|
|
|
|
"error", err,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return reader, manifestBytes, manifest, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
func openPluginArchive(data []byte) (*zip.Reader, []byte, *pluginv1.PluginManifest, error) {
|
|
|
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("open plugin archive: %w", 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 {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("plugin archive is missing manifest.json")
|
|
|
|
|
}
|
|
|
|
|
binaryFile, ok := files["plugin"]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("plugin archive is missing plugin binary")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
manifestBytes, err := readZipFile(manifestFile)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
manifest, err := LoadManifestBytes(manifestBytes)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
binaryBytes, err := readZipFile(binaryFile)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
checksum := sha256.Sum256(binaryBytes)
|
|
|
|
|
if manifest.GetChecksum() != hex.EncodeToString(checksum[:]) {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("plugin binary checksum does not match manifest")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, asset := range manifest.GetAssets() {
|
|
|
|
|
if _, ok := files[asset.GetPath()]; !ok {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("plugin archive is missing packaged asset %q", asset.GetPath())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return reader, manifestBytes, manifest, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 08:13:50 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
func extractArchiveFiles(reader *zip.Reader, root string) error {
|
|
|
|
|
for _, file := range reader.File {
|
|
|
|
|
if err := extractZipFile(file, root); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateInstalledFiles(binaryPath string, manifest *pluginv1.PluginManifest) error {
|
|
|
|
|
if err := installedFilesPresent(binaryPath, manifest); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
binaryBytes, err := os.ReadFile(binaryPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("read plugin binary %q: %w", binaryPath, err)
|
|
|
|
|
}
|
|
|
|
|
checksum := sha256.Sum256(binaryBytes)
|
|
|
|
|
if manifest.GetChecksum() != hex.EncodeToString(checksum[:]) {
|
|
|
|
|
return fmt.Errorf("plugin binary checksum does not match manifest")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func installedFilesPresent(binaryPath string, manifest *pluginv1.PluginManifest) error {
|
|
|
|
|
binaryInfo, err := os.Stat(binaryPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("plugin binary %q: %w", binaryPath, err)
|
|
|
|
|
}
|
|
|
|
|
if binaryInfo.IsDir() {
|
|
|
|
|
return fmt.Errorf("plugin binary %q is a directory", binaryPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, asset := range manifest.GetAssets() {
|
|
|
|
|
resolved := filepath.Join(filepath.Dir(binaryPath), asset.GetPath())
|
|
|
|
|
info, err := os.Stat(resolved)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("plugin asset %q: %w", asset.GetPath(), err)
|
|
|
|
|
}
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
return fmt.Errorf("plugin asset %q resolved to a directory", asset.GetPath())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|