package plugins import ( "archive/zip" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" publicmanifest "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/manifest" "google.golang.org/protobuf/encoding/protojson" ) type InstallArchiveRequest struct { ArchivePath string ArchiveURL string RepositoryID *int } type InstallBinaryRequest struct { BinaryURL string Checksum string // expected SHA-256 hex checksum Manifest *pluginv1.PluginManifest RepositoryID *int } type InstallResult struct { Installation *Installation Manifest *pluginv1.PluginManifest BinaryPath string ManifestPath string } type InstallerOptions struct { BaseDir string HTTPClient *http.Client } type installationStore interface { Create(ctx context.Context, input CreateInstallationInput) (*Installation, error) SaveArchive(ctx context.Context, installationID int, manifestJSON []byte, checksum string, archiveBytes []byte) error Update(ctx context.Context, id int, input UpdateInstallationInput) error Delete(ctx context.Context, id int) error } type Installer struct { installations installationStore baseDir string httpClient *http.Client } func NewInstaller(installations installationStore, opts InstallerOptions) *Installer { httpClient := opts.HTTPClient if httpClient == nil { httpClient = http.DefaultClient } baseDir := opts.BaseDir if baseDir == "" { baseDir = filepath.Join(os.TempDir(), "silo-plugins") } return &Installer{ installations: installations, baseDir: baseDir, httpClient: httpClient, } } func (i *Installer) InstallLocal(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) { if req.ArchivePath == "" { return nil, fmt.Errorf("archive path is required") } data, err := os.ReadFile(req.ArchivePath) if err != nil { return nil, fmt.Errorf("read archive %q: %w", req.ArchivePath, err) } return i.installArchive(ctx, data, req.RepositoryID) } func (i *Installer) ReplaceLocal(ctx context.Context, existing *Installation, req InstallArchiveRequest) (*InstallResult, error) { if existing == nil { return nil, fmt.Errorf("existing installation is required") } if req.ArchivePath == "" { return nil, fmt.Errorf("archive path is required") } data, err := os.ReadFile(req.ArchivePath) if err != nil { return nil, fmt.Errorf("read archive %q: %w", req.ArchivePath, err) } return i.replaceArchive(ctx, existing, data, req.RepositoryID) } func (i *Installer) InstallRemote(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) { if req.ArchiveURL == "" { return nil, fmt.Errorf("archive url is required") } data, err := i.downloadArchive(ctx, req.ArchiveURL) if err != nil { return nil, err } return i.installArchive(ctx, data, req.RepositoryID) } func (i *Installer) ReplaceRemote(ctx context.Context, existing *Installation, req InstallArchiveRequest) (*InstallResult, error) { if req.ArchiveURL == "" { return nil, fmt.Errorf("archive url is required") } data, err := i.downloadArchive(ctx, req.ArchiveURL) if err != nil { return nil, err } return i.replaceArchive(ctx, existing, data, req.RepositoryID) } func (i *Installer) downloadArchive(ctx context.Context, archiveURL string) ([]byte, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil) if err != nil { return nil, fmt.Errorf("build archive request: %w", err) } resp, err := i.httpClient.Do(request) if err != nil { return nil, fmt.Errorf("download archive %q: %w", archiveURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("download archive %q: unexpected status %d", archiveURL, resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read archive response: %w", err) } return data, nil } func (i *Installer) InstallBinary(ctx context.Context, req InstallBinaryRequest) (*InstallResult, error) { if req.BinaryURL == "" { return nil, fmt.Errorf("binary url is required") } if req.Checksum == "" { return nil, fmt.Errorf("binary checksum is required") } binaryData, manifest, actualChecksum, err := i.downloadBinary(ctx, req) if err != nil { return nil, err } return i.installBinary(ctx, binaryData, actualChecksum, manifest, req.RepositoryID) } func (i *Installer) ReplaceBinary(ctx context.Context, existing *Installation, req InstallBinaryRequest) (*InstallResult, error) { if req.BinaryURL == "" { return nil, fmt.Errorf("binary url is required") } if req.Checksum == "" { return nil, fmt.Errorf("binary checksum is required") } binaryData, manifest, actualChecksum, err := i.downloadBinary(ctx, req) if err != nil { return nil, err } return i.replaceBinary(ctx, existing, binaryData, actualChecksum, manifest) } func (i *Installer) downloadBinary( ctx context.Context, req InstallBinaryRequest, ) ([]byte, *pluginv1.PluginManifest, string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, req.BinaryURL, nil) if err != nil { return nil, nil, "", fmt.Errorf("build binary request: %w", err) } resp, err := i.httpClient.Do(request) if err != nil { return nil, nil, "", fmt.Errorf("download binary %q: %w", req.BinaryURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, nil, "", fmt.Errorf("download binary %q: unexpected status %d", req.BinaryURL, resp.StatusCode) } binaryData, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, "", fmt.Errorf("read binary response: %w", err) } checksum := sha256.Sum256(binaryData) actualChecksum := hex.EncodeToString(checksum[:]) if actualChecksum != req.Checksum { return nil, nil, "", fmt.Errorf("binary checksum mismatch: expected %s, got %s", req.Checksum, actualChecksum) } manifest := req.Manifest if manifest == nil { manifest, err = loadManifestFromBinary(ctx, binaryData) if err != nil { return nil, nil, "", err } } else if err := ValidateManifest(manifest); err != nil { return nil, nil, "", err } return binaryData, manifest, actualChecksum, nil } func (i *Installer) InstallBinaryUpload(ctx context.Context, binaryData []byte) (*InstallResult, error) { if len(binaryData) == 0 { return nil, fmt.Errorf("binary data is required") } manifest, err := loadManifestFromBinary(ctx, binaryData) if err != nil { return nil, err } checksum := sha256.Sum256(binaryData) actualChecksum := hex.EncodeToString(checksum[:]) return i.installBinary(ctx, binaryData, actualChecksum, manifest, nil) } func (i *Installer) replaceBinary( ctx context.Context, existing *Installation, binaryData []byte, checksum string, manifest *pluginv1.PluginManifest, ) (*InstallResult, error) { if existing == nil { return nil, fmt.Errorf("existing installation is required") } if len(binaryData) == 0 { return nil, fmt.Errorf("binary data is required") } if checksum == "" { return nil, fmt.Errorf("binary checksum is required") } if manifest == nil { return nil, fmt.Errorf("plugin manifest is required") } if err := ValidateManifest(manifest); err != nil { return nil, err } manifestBytes, err := protojson.Marshal(manifest) if err != nil { return nil, fmt.Errorf("serialize plugin manifest: %w", err) } installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion())) if err := os.MkdirAll(installRoot, 0755); err != nil { return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err) } installDir, err := os.MkdirTemp(installRoot, "install-") if err != nil { return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err) } keepInstallDir := false defer func() { if keepInstallDir { return } _ = os.RemoveAll(installDir) }() binaryPath := filepath.Join(installDir, "plugin") manifestPath := filepath.Join(installDir, "manifest.json") if err := os.WriteFile(binaryPath, binaryData, 0755); err != nil { return nil, fmt.Errorf("write plugin binary %q: %w", binaryPath, err) } if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { return nil, fmt.Errorf("write plugin manifest %q: %w", manifestPath, err) } capabilities, err := CapabilityRecordsFromManifest(manifest) if err != nil { return nil, err } if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, checksum, binaryData); err != nil { return nil, fmt.Errorf("persist replacement plugin archive: %w", err) } version := manifest.GetVersion() enabled := existing.Enabled availableVersion := "" if err := i.installations.Update(ctx, existing.ID, UpdateInstallationInput{ Version: &version, InstallPath: &binaryPath, Enabled: &enabled, AvailableVersion: &availableVersion, Capabilities: capabilities, }); err != nil { return nil, fmt.Errorf("update replacement plugin installation: %w", err) } oldInstallDir := filepath.Dir(existing.InstallPath) if oldInstallDir != "" && filepath.Clean(oldInstallDir) != filepath.Clean(installDir) { _ = os.RemoveAll(oldInstallDir) } keepInstallDir = true updated := *existing updated.Version = version updated.InstallPath = binaryPath updated.Enabled = enabled return &InstallResult{ Installation: &updated, Manifest: manifest, BinaryPath: binaryPath, ManifestPath: manifestPath, }, nil } func (i *Installer) replaceArchive( ctx context.Context, existing *Installation, data []byte, repositoryID *int, ) (*InstallResult, error) { if existing == nil { return nil, fmt.Errorf("existing installation is required") } reader, manifestBytes, manifest, err := openPluginArchive(data) if err != nil { return nil, err } _ = repositoryID installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion())) if err := os.MkdirAll(installRoot, 0755); err != nil { return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err) } installDir, err := os.MkdirTemp(installRoot, "install-") if err != nil { return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err) } keepInstallDir := false defer func() { if keepInstallDir { return } _ = os.RemoveAll(installDir) }() binaryPath := filepath.Join(installDir, "plugin") manifestPath := filepath.Join(installDir, "manifest.json") capabilities, err := CapabilityRecordsFromManifest(manifest) if err != nil { return nil, err } if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, manifest.GetChecksum(), data); err != nil { return nil, fmt.Errorf("persist replacement plugin archive: %w", err) } if err := extractArchiveFiles(reader, installDir); err != nil { return nil, err } version := manifest.GetVersion() enabled := existing.Enabled availableVersion := "" if err := i.installations.Update(ctx, existing.ID, UpdateInstallationInput{ Version: &version, InstallPath: &binaryPath, Enabled: &enabled, AvailableVersion: &availableVersion, Capabilities: capabilities, }); err != nil { return nil, fmt.Errorf("update replacement plugin installation: %w", err) } oldInstallDir := filepath.Dir(existing.InstallPath) if oldInstallDir != "" && filepath.Clean(oldInstallDir) != filepath.Clean(installDir) { _ = os.RemoveAll(oldInstallDir) } keepInstallDir = true updated := *existing updated.Version = version updated.InstallPath = binaryPath updated.Enabled = enabled return &InstallResult{ Installation: &updated, Manifest: manifest, BinaryPath: binaryPath, ManifestPath: manifestPath, }, nil } func (i *Installer) installBinary(ctx context.Context, binaryData []byte, checksum string, manifest *pluginv1.PluginManifest, repositoryID *int) (*InstallResult, error) { manifestBytes, err := protojson.Marshal(manifest) if err != nil { return nil, fmt.Errorf("serialize plugin manifest: %w", err) } installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion())) if err := os.MkdirAll(installRoot, 0755); err != nil { return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err) } installDir, err := os.MkdirTemp(installRoot, "install-") if err != nil { return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err) } keepInstallDir := false defer func() { if keepInstallDir { return } _ = os.RemoveAll(installDir) }() binaryPath := filepath.Join(installDir, "plugin") manifestPath := filepath.Join(installDir, "manifest.json") if err := os.WriteFile(binaryPath, binaryData, 0755); err != nil { return nil, fmt.Errorf("write plugin binary %q: %w", binaryPath, err) } if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { return nil, fmt.Errorf("write plugin manifest %q: %w", manifestPath, err) } capabilities, err := CapabilityRecordsFromManifest(manifest) if err != nil { return nil, err } createInput := CreateInstallationInput{ PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(), InstallPath: binaryPath, Enabled: false, Capabilities: capabilities, } if repositoryID != nil { createInput.RepositoryID = *repositoryID } installation, err := i.installations.Create(ctx, createInput) if err != nil { return nil, fmt.Errorf("persist plugin installation: %w", err) } if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, checksum, binaryData); 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) } return nil, fmt.Errorf("persist plugin archive: %w", err) } enabled := true if err := i.installations.Update(ctx, installation.ID, UpdateInstallationInput{ Enabled: &enabled, }); err != nil { if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil { return nil, fmt.Errorf("enable plugin installation: %w (cleanup failed: %v)", err, deleteErr) } return nil, fmt.Errorf("enable plugin installation: %w", err) } installation.Enabled = true keepInstallDir = true return &InstallResult{ Installation: installation, Manifest: manifest, BinaryPath: binaryPath, ManifestPath: manifestPath, }, nil } func (i *Installer) installArchive(ctx context.Context, data []byte, repositoryID *int) (*InstallResult, error) { reader, manifestBytes, manifest, err := openPluginArchive(data) if err != nil { return nil, err } installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion())) if err := os.MkdirAll(installRoot, 0755); err != nil { return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err) } installDir, err := os.MkdirTemp(installRoot, "install-") if err != nil { return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err) } keepInstallDir := false defer func() { if keepInstallDir { return } _ = os.RemoveAll(installDir) }() binaryPath := filepath.Join(installDir, "plugin") manifestPath := filepath.Join(installDir, "manifest.json") capabilities, err := CapabilityRecordsFromManifest(manifest) if err != nil { return nil, err } createInput := CreateInstallationInput{ PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(), InstallPath: binaryPath, Enabled: false, Capabilities: capabilities, } if repositoryID != nil { createInput.RepositoryID = *repositoryID } installation, err := i.installations.Create(ctx, createInput) if err != nil { return nil, fmt.Errorf("persist plugin installation: %w", err) } if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, manifest.GetChecksum(), data); 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) } return nil, fmt.Errorf("persist plugin archive: %w", err) } if err := extractArchiveFiles(reader, installDir); err != nil { if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil { return nil, fmt.Errorf("extract plugin archive: %w (cleanup failed: %v)", err, deleteErr) } return nil, err } enabled := true if err := i.installations.Update(ctx, installation.ID, UpdateInstallationInput{ Enabled: &enabled, }); err != nil { if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil { return nil, fmt.Errorf("enable plugin installation: %w (cleanup failed: %v)", err, deleteErr) } return nil, fmt.Errorf("enable plugin installation: %w", err) } installation.Enabled = true keepInstallDir = true return &InstallResult{ Installation: installation, Manifest: manifest, BinaryPath: binaryPath, ManifestPath: manifestPath, }, nil } func LoadManifestBytes(data []byte) (*pluginv1.PluginManifest, error) { manifest, err := publicmanifest.Load(data) if err != nil { return nil, err } if err := ValidateManifest(manifest); err != nil { return nil, err } return manifest, nil } func loadManifestFromBinary(ctx context.Context, binaryData []byte) (*pluginv1.PluginManifest, error) { tmpDir, err := os.MkdirTemp("", "plugin-manifest-") if err != nil { return nil, fmt.Errorf("create temp dir for binary manifest: %w", err) } defer os.RemoveAll(tmpDir) tmpBinary := filepath.Join(tmpDir, "plugin") if err := os.WriteFile(tmpBinary, binaryData, 0755); err != nil { return nil, fmt.Errorf("write temp binary for manifest: %w", err) } execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(execCtx, tmpBinary, "manifest") manifestOut, err := cmd.Output() if err != nil { return nil, fmt.Errorf("execute plugin manifest command: %w", err) } var manifest pluginv1.PluginManifest if err := protojson.Unmarshal(manifestOut, &manifest); err != nil { return nil, fmt.Errorf("parse plugin manifest output: %w", err) } if err := ValidateManifest(&manifest); err != nil { return nil, err } return &manifest, nil } func extractZipFile(file *zip.File, root string) error { if err := validateRelativePath(file.Name, "plugin archive"); err != nil { return err } targetPath := filepath.Join(root, filepath.Clean(file.Name)) if file.FileInfo().IsDir() { if err := os.MkdirAll(targetPath, 0755); err != nil { return fmt.Errorf("create plugin directory %q: %w", targetPath, err) } return nil } if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return fmt.Errorf("create plugin parent dir %q: %w", targetPath, err) } data, err := readZipFile(file) if err != nil { return err } mode := os.FileMode(0644) if filepath.Base(file.Name) == "plugin" { mode = 0755 } if err := os.WriteFile(targetPath, data, mode); err != nil { return fmt.Errorf("write plugin file %q: %w", targetPath, err) } return nil } func readZipFile(file *zip.File) ([]byte, error) { reader, err := file.Open() if err != nil { return nil, fmt.Errorf("open plugin archive entry %q: %w", file.Name, err) } defer reader.Close() data, err := io.ReadAll(reader) if err != nil { return nil, fmt.Errorf("read plugin archive entry %q: %w", file.Name, err) } return data, nil } func sanitizeFilesystemSegment(value string) string { replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "@", "_") return replacer.Replace(value) }