// 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 }