package plugins import ( "encoding/json" "fmt" "net/url" "os" "path/filepath" "regexp" "sort" "strings" "github.com/spf13/afero" ) const pluginLockfileVersion = 1 var gitCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) var scpLikeRepoPattern = regexp.MustCompile(`^[^@:/\s]+@[^@:/\s]+:\S+$`) type PluginLockfile struct { LockfileVersion int `json:"lockfileVersion"` Plugins map[string]LockedPlugin `json:"plugins"` } type LockedPlugin struct { Repo string `json:"repo"` Path string `json:"path,omitempty"` Commit string `json:"commit"` } type LockStore struct { fs afero.Fs path string } func NewLockStore(fs afero.Fs, path string) *LockStore { return &LockStore{fs: fs, path: path} } func NewPluginLockfile() PluginLockfile { return PluginLockfile{ LockfileVersion: pluginLockfileVersion, Plugins: make(map[string]LockedPlugin), } } func (s *LockStore) Path() string { return s.path } func (s *LockStore) Exists() (bool, error) { return afero.Exists(s.fs, s.path) } func (s *LockStore) Load() (PluginLockfile, error) { data, err := afero.ReadFile(s.fs, s.path) if err != nil { if os.IsNotExist(err) { return NewPluginLockfile(), nil } return PluginLockfile{}, fmt.Errorf("failed to read plugin lockfile: %w", err) } return ParsePluginLockfile(data) } func ParsePluginLockfile(data []byte) (PluginLockfile, error) { var lock PluginLockfile if err := json.Unmarshal(data, &lock); err != nil { return PluginLockfile{}, fmt.Errorf("failed to parse plugin lockfile: %w", err) } if lock.Plugins == nil { lock.Plugins = make(map[string]LockedPlugin) } if err := lock.Validate(); err != nil { return PluginLockfile{}, err } return lock, nil } func (s *LockStore) Write(lock PluginLockfile) error { if err := lock.Validate(); err != nil { return err } data, err := json.MarshalIndent(lock, "", " ") if err != nil { return fmt.Errorf("failed to encode plugin lockfile: %w", err) } data = append(data, '\n') if err := s.fs.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return fmt.Errorf("failed to create plugin lockfile directory: %w", err) } tmpPath := s.path + ".tmp" if err := afero.WriteFile(s.fs, tmpPath, data, 0o644); err != nil { return fmt.Errorf("failed to write temporary plugin lockfile: %w", err) } defer s.fs.Remove(tmpPath) //nolint:errcheck if err := s.fs.Rename(tmpPath, s.path); err != nil { return fmt.Errorf("failed to replace plugin lockfile: %w", err) } return nil } func (lock PluginLockfile) Validate() error { if lock.LockfileVersion != pluginLockfileVersion { return fmt.Errorf("unsupported plugin lockfile version %d (expected %d)", lock.LockfileVersion, pluginLockfileVersion) } repoCommits := make(map[string]string) for id, plugin := range lock.Plugins { if !isSafePluginPathComponent(id) { return fmt.Errorf("invalid locked plugin id: %q", id) } if strings.TrimSpace(plugin.Repo) == "" { return fmt.Errorf("locked plugin %q has no repository", id) } if err := validatePluginRepo(plugin.Repo); err != nil { return fmt.Errorf("locked plugin %q: %w", id, err) } if !gitCommitPattern.MatchString(plugin.Commit) { return fmt.Errorf("locked plugin %q has invalid commit %q", id, plugin.Commit) } if err := validatePluginRepoPath(plugin.Path); err != nil { return fmt.Errorf("locked plugin %q: %w", id, err) } if commit, ok := repoCommits[plugin.Repo]; ok && !strings.EqualFold(commit, plugin.Commit) { return fmt.Errorf("plugins from repository %q must use the same commit", plugin.Repo) } repoCommits[plugin.Repo] = plugin.Commit } return nil } func validatePluginRepo(repo string) error { parsed, err := url.Parse(repo) if err != nil { if scpLikeRepoPattern.MatchString(repo) { return nil } return fmt.Errorf("invalid repository URL %q", repo) } if parsed.User == nil { return nil } if _, hasPassword := parsed.User.Password(); hasPassword { return fmt.Errorf("repository URL must not contain credentials") } switch parsed.Scheme { case "http", "https": return fmt.Errorf("repository URL must not contain credentials") } return nil } func validatePluginRepoPath(path string) error { if path == "" { return nil } clean := filepath.Clean(path) if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return fmt.Errorf("invalid repository path %q", path) } return nil } func (lock PluginLockfile) IDs() []string { ids := make([]string, 0, len(lock.Plugins)) for id := range lock.Plugins { ids = append(ids, id) } sort.Strings(ids) return ids } func (lock *PluginLockfile) SetRepositoryCommit(repo, commit string) { for id, plugin := range lock.Plugins { if plugin.Repo != repo { continue } plugin.Commit = commit lock.Plugins[id] = plugin } } func (lock PluginLockfile) Clone() PluginLockfile { cloned := NewPluginLockfile() for id, plugin := range lock.Plugins { cloned.Plugins[id] = plugin } return cloned }