246 lines
7.9 KiB
Go
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
|
|
}
|