Files

246 lines
7.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"github.com/pelletier/go-toml/v2"
)
// Entry records an explicitly added local program or a verified package.
// Discovery never adds entries. Enablement is desired state; failures retain it.
type Entry struct {
ID string `toml:"id"`
Program []string `toml:"program"`
Directory string `toml:"directory,omitempty"`
Enabled bool `toml:"enabled"`
Presentation bool `toml:"presentation"`
Interaction bool `toml:"interaction,omitempty"`
Repository string `toml:"repository,omitempty"`
RepositoryID string `toml:"repository-id,omitempty"`
LocalID string `toml:"local-id,omitempty"`
Name string `toml:"name,omitempty"`
Version string `toml:"package-version,omitempty"`
PackageDirectory string `toml:"package-directory,omitempty"`
PackageSHA256 string `toml:"package-sha256,omitempty"`
RepositoryRevision string `toml:"repository-revision,omitempty"`
}
func (entry Entry) Spec() Spec {
return Spec{ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction}
}
type registryDocument struct {
Version int `toml:"version"`
Entries []Entry `toml:"tentacles"`
Repositories []Repository `toml:"repositories,omitempty"`
}
// Registry is owned by the reader lifecycle worker, independently of profiles.
// Its optimistic revision prevents overwriting another editor's changes.
type Registry struct {
Path string
original []byte
loaded bool
repositories []Repository
}
var ErrRegistrySync = errors.New("tentacle registry was replaced but its directory could not be synced")
func RegistryPath(settingsPath string) string {
return filepath.Join(filepath.Dir(settingsPath), "tentacles.toml")
}
func CloneEntries(entries []Entry) []Entry {
result := append([]Entry(nil), entries...)
for index := range result {
result[index].Program = append([]string(nil), result[index].Program...)
}
return result
}
func ValidateEntries(entries []Entry) error {
if len(entries) > DefaultLimits().Processes {
return ErrLimit
}
seen := make(map[string]bool)
packageDirectories := make(map[string]bool)
for _, entry := range entries {
if err := validate_spec(entry.Spec()); err != nil {
return err
}
if entry.Repository != "" {
origin, err := RepositoryURL(entry.Repository)
if err != nil || origin != entry.Repository || !metadata_id(entry.RepositoryID) || !metadata_id(entry.LocalID) {
return errors.New("invalid installed tentacle source identity")
}
} else if entry.RepositoryID != "" || entry.LocalID != "" {
return errors.New("tentacle source identity requires a repository origin")
}
if !metadata_text(entry.Name, false) || !metadata_text(entry.Version, false) {
return errors.New("invalid installed tentacle display metadata")
}
if seen[entry.ID] {
return errors.New("duplicate tentacle ID")
}
if entry.PackageDirectory != "" {
relativeProgram, err := filepath.Rel(entry.PackageDirectory, entry.Program[0])
if entry.Repository == "" || !filepath.IsAbs(entry.PackageDirectory) ||
filepath.Clean(entry.PackageDirectory) != entry.PackageDirectory || entry.Directory != entry.PackageDirectory ||
!valid_digest(entry.PackageSHA256) || !valid_revision(entry.RepositoryRevision) ||
entry.ID != PackageID(Repository{URL: entry.Repository, ID: entry.RepositoryID}, entry.LocalID) ||
err != nil || !package_path(filepath.ToSlash(relativeProgram)) || packageDirectories[entry.PackageDirectory] {
return errors.New("invalid installed package lock")
}
packageDirectories[entry.PackageDirectory] = true
} else if entry.PackageSHA256 != "" || entry.RepositoryRevision != "" {
return errors.New("package lock requires a package directory")
}
seen[entry.ID] = true
}
data, err := toml.Marshal(registryDocument{Version: 1, Entries: entries})
if err == nil && len(data) > MaxFrameBytes {
return ErrLimit
}
return err
}
func read_registry(path string) ([]byte, error) {
file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("tentacle registry must be a regular file")
}
data, err := io.ReadAll(io.LimitReader(file, MaxFrameBytes+1))
if len(data) > MaxFrameBytes {
return nil, ErrLimit
}
return data, err
}
func (registry *Registry) Load() ([]Entry, error) {
registry.loaded = false
data, err := read_registry(registry.Path)
if err != nil {
return nil, err
}
var document registryDocument
if data != nil {
decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
return nil, err
}
if document.Version != 1 {
return nil, errors.New("unsupported tentacle registry version")
}
if err := ValidateEntries(document.Entries); err != nil {
return nil, err
}
if err := ValidateRepositories(document.Repositories); err != nil {
return nil, err
}
}
registry.original, registry.loaded = data, true
registry.repositories = CloneRepositories(document.Repositories)
return CloneEntries(document.Entries), nil
}
// Save validates before touching disk, locks cooperating writers, and atomically
// replaces an owner-only file. A failed load can never be overwritten.
func (registry *Registry) Save(entries []Entry) error {
return registry.SaveState(entries, registry.repositories)
}
func (registry *Registry) Repositories() []Repository {
return CloneRepositories(registry.repositories)
}
func (registry *Registry) SaveState(entries []Entry, repositories []Repository) error {
if !registry.loaded {
return errors.New("tentacle registry has not been loaded successfully")
}
if err := ValidateEntries(entries); err != nil {
return err
}
if err := ValidateRepositories(repositories); err != nil {
return err
}
data, err := toml.Marshal(registryDocument{Version: 1, Entries: entries, Repositories: repositories})
if err != nil {
return err
}
if len(data) > MaxFrameBytes {
return ErrLimit
}
directory := filepath.Dir(registry.Path)
if err := os.MkdirAll(directory, 0700); err != nil {
return err
}
lock, err := os.OpenFile(registry.Path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600)
if err != nil {
return err
}
defer lock.Close()
if info, err := lock.Stat(); err != nil || !info.Mode().IsRegular() {
return errors.New("tentacle registry lock must be a regular file")
}
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
return errors.New("tentacle registry is being edited")
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
current, err := read_registry(registry.Path)
if err != nil {
return err
}
if !bytes.Equal(current, registry.original) || (current == nil) != (registry.original == nil) {
return errors.New("tentacle registry changed outside the reader; restart before editing")
}
temporary, err := os.CreateTemp(directory, ".tentacles-*.toml")
if err != nil {
return err
}
defer os.Remove(temporary.Name())
defer temporary.Close()
if _, err := temporary.Write(data); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
dir, err := os.Open(directory)
if err != nil {
return err
}
defer dir.Close()
if err := os.Rename(temporary.Name(), registry.Path); err != nil {
return err
}
registry.original = data
registry.repositories = CloneRepositories(repositories)
if err := dir.Sync(); err != nil {
return fmt.Errorf("%w: %w", ErrRegistrySync, err)
}
return nil
}