Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
250 lines
8.3 KiB
Go
250 lines
8.3 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 {
|
|
SessionEvents bool `toml:"session-events,omitempty"`
|
|
RemoteGrants []string `toml:"remote-grants,omitempty"`
|
|
ID string `toml:"id"`
|
|
Program []string `toml:"program"`
|
|
Directory string `toml:"directory,omitempty"`
|
|
Enabled bool `toml:"enabled"`
|
|
Presentation bool `toml:"presentation"`
|
|
ReaderSettings bool `toml:"reader-settings,omitempty"`
|
|
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{SessionEvents: entry.SessionEvents, RemoteGrants: append([]string(nil), entry.RemoteGrants...), ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction, ReaderSettings: entry.ReaderSettings}
|
|
}
|
|
|
|
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...)
|
|
result[index].RemoteGrants = append([]string(nil), result[index].RemoteGrants...)
|
|
}
|
|
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
|
|
}
|