go-scm/plugin/installer.go
Virgil ae0677a046 fix(security): harden installer, marketplace, and sync path handling
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-23 14:32:11 +00:00

225 lines
6.6 KiB
Go

package plugin
import (
"context"
"fmt"
"net/url"
"os/exec"
"path/filepath"
"strings"
"time"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/scm/agentci"
)
// Installer handles plugin installation from GitHub.
type Installer struct {
medium io.Medium
registry *Registry
}
// NewInstaller creates a new plugin installer.
func NewInstaller(m io.Medium, registry *Registry) *Installer {
return &Installer{
medium: m,
registry: registry,
}
}
// Install downloads and installs a plugin from GitHub.
// The source format is "org/repo" or "org/repo@version".
func (i *Installer) Install(ctx context.Context, source string) error {
org, repo, version, err := ParseSource(source)
if err != nil {
return coreerr.E("plugin.Installer.Install", "invalid source", err)
}
// Check if already installed
if _, exists := i.registry.Get(repo); exists {
return coreerr.E("plugin.Installer.Install", "plugin already installed: "+repo, nil)
}
// Clone the repository
_, pluginDir, err := i.resolvePluginPath(repo)
if err != nil {
return coreerr.E("plugin.Installer.Install", "invalid plugin path", err)
}
if err := i.medium.EnsureDir(pluginDir); err != nil {
return coreerr.E("plugin.Installer.Install", "failed to create plugin directory", err)
}
if err := i.cloneRepo(ctx, org, repo, version, pluginDir); err != nil {
return coreerr.E("plugin.Installer.Install", "failed to clone repository", err)
}
// Load and validate manifest
manifestPath := filepath.Join(pluginDir, "plugin.json")
manifest, err := LoadManifest(i.medium, manifestPath)
if err != nil {
// Clean up on failure
_ = i.medium.DeleteAll(pluginDir)
return coreerr.E("plugin.Installer.Install", "failed to load manifest", err)
}
if err := manifest.Validate(); err != nil {
_ = i.medium.DeleteAll(pluginDir)
return coreerr.E("plugin.Installer.Install", "invalid manifest", err)
}
// Resolve version
if version == "" {
version = manifest.Version
}
// Register in the registry
cfg := &PluginConfig{
Name: manifest.Name,
Version: version,
Source: fmt.Sprintf("github:%s/%s", org, repo),
Enabled: true,
InstalledAt: time.Now().UTC().Format(time.RFC3339),
}
if err := i.registry.Add(cfg); err != nil {
return coreerr.E("plugin.Installer.Install", "failed to register plugin", err)
}
if err := i.registry.Save(); err != nil {
return coreerr.E("plugin.Installer.Install", "failed to save registry", err)
}
return nil
}
// Update updates a plugin to the latest version.
func (i *Installer) Update(ctx context.Context, name string) error {
safeName, pluginDir, err := i.resolvePluginPath(name)
if err != nil {
return coreerr.E("plugin.Installer.Update", "invalid plugin name", err)
}
cfg, ok := i.registry.Get(safeName)
if !ok {
return coreerr.E("plugin.Installer.Update", "plugin not found: "+safeName, nil)
}
// Pull latest changes
cmd := exec.CommandContext(ctx, "git", "-C", pluginDir, "pull", "--ff-only")
if output, err := cmd.CombinedOutput(); err != nil {
return coreerr.E("plugin.Installer.Update", "failed to pull updates: "+strings.TrimSpace(string(output)), err)
}
// Reload manifest to get updated version
manifestPath := filepath.Join(pluginDir, "plugin.json")
manifest, err := LoadManifest(i.medium, manifestPath)
if err != nil {
return coreerr.E("plugin.Installer.Update", "failed to read updated manifest", err)
}
// Update registry
cfg.Version = manifest.Version
if err := i.registry.Save(); err != nil {
return coreerr.E("plugin.Installer.Update", "failed to save registry", err)
}
return nil
}
// Remove uninstalls a plugin by removing its files and registry entry.
func (i *Installer) Remove(name string) error {
safeName, pluginDir, err := i.resolvePluginPath(name)
if err != nil {
return coreerr.E("plugin.Installer.Remove", "invalid plugin name", err)
}
if _, ok := i.registry.Get(safeName); !ok {
return coreerr.E("plugin.Installer.Remove", "plugin not found: "+safeName, nil)
}
// Delete plugin directory
if i.medium.Exists(pluginDir) {
if err := i.medium.DeleteAll(pluginDir); err != nil {
return coreerr.E("plugin.Installer.Remove", "failed to delete plugin files", err)
}
}
// Remove from registry
if err := i.registry.Remove(safeName); err != nil {
return coreerr.E("plugin.Installer.Remove", "failed to unregister plugin", err)
}
if err := i.registry.Save(); err != nil {
return coreerr.E("plugin.Installer.Remove", "failed to save registry", err)
}
return nil
}
// cloneRepo clones a GitHub repository using the gh CLI.
func (i *Installer) cloneRepo(ctx context.Context, org, repo, version, dest string) error {
repoURL := fmt.Sprintf("%s/%s", org, repo)
args := []string{"repo", "clone", repoURL, dest}
if version != "" {
args = append(args, "--", "--branch", version)
}
cmd := exec.CommandContext(ctx, "gh", args...)
if output, err := cmd.CombinedOutput(); err != nil {
return coreerr.E("plugin.Installer.cloneRepo", strings.TrimSpace(string(output)), err)
}
return nil
}
// ParseSource parses a plugin source string into org, repo, and version.
// Accepted formats:
// - "org/repo" -> org="org", repo="repo", version=""
// - "org/repo@v1.0" -> org="org", repo="repo", version="v1.0"
func ParseSource(source string) (org, repo, version string, err error) {
source, err = url.PathUnescape(source)
if err != nil {
return "", "", "", coreerr.E("plugin.ParseSource", "invalid source path", err)
}
if source == "" {
return "", "", "", coreerr.E("plugin.ParseSource", "source is empty", nil)
}
// Split off version if present
atIdx := strings.LastIndex(source, "@")
path := source
if atIdx != -1 {
path = source[:atIdx]
version = source[atIdx+1:]
if version == "" {
return "", "", "", coreerr.E("plugin.ParseSource", "version is empty after @", nil)
}
}
// Split org/repo
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", "", coreerr.E("plugin.ParseSource", "source must be in format org/repo[@version]", nil)
}
org, err = agentci.ValidatePathElement(parts[0])
if err != nil {
return "", "", "", coreerr.E("plugin.ParseSource", "invalid org", err)
}
repo, err = agentci.ValidatePathElement(parts[1])
if err != nil {
return "", "", "", coreerr.E("plugin.ParseSource", "invalid repo", err)
}
return org, repo, version, nil
}
func (i *Installer) resolvePluginPath(name string) (string, string, error) {
safeName, path, err := agentci.ResolvePathWithinRoot(i.registry.basePath, name)
if err != nil {
return "", "", coreerr.E("plugin.Installer.resolvePluginPath", "resolve plugin path", err)
}
return safeName, path, nil
}