286 lines
8.1 KiB
Go
286 lines
8.1 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
)
|
|
|
|
// Store owns the versioned Go configuration. An explicitly selected
|
|
// Python-era file fails strict decoding and cannot be replaced by Save.
|
|
type Store struct {
|
|
ioMu sync.Mutex
|
|
mu sync.RWMutex
|
|
path string
|
|
lastGood Settings
|
|
rename func(string, string) error
|
|
}
|
|
|
|
func DefaultStorePath() (string, error) { return DefaultPath() }
|
|
|
|
func NewStore(path string) (*Store, error) {
|
|
if path == "" {
|
|
var err error
|
|
path, err = DefaultStorePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
defaults := DefaultSettings()
|
|
defaults.Path = path
|
|
return &Store{path: path, lastGood: cloneSettings(defaults), rename: os.Rename}, nil
|
|
}
|
|
|
|
func (store *Store) Path() string { return store.path }
|
|
|
|
// Current returns a copy of the last configuration which parsed and validated.
|
|
func (store *Store) Current() Settings {
|
|
store.mu.RLock()
|
|
defer store.mu.RUnlock()
|
|
return cloneSettings(store.lastGood)
|
|
}
|
|
|
|
// Load returns the last known good configuration together with any read,
|
|
// parse, schema, or validation error. A missing file selects built-in defaults.
|
|
func (store *Store) Load() (Settings, error) {
|
|
store.ioMu.Lock()
|
|
defer store.ioMu.Unlock()
|
|
contents, err := readStoreFile(store.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
defaults := DefaultSettings()
|
|
defaults.Path = store.path
|
|
store.setCurrent(defaults)
|
|
return cloneSettings(defaults), nil
|
|
}
|
|
if err != nil {
|
|
return store.Current(), err
|
|
}
|
|
_, settings, err := decodeDocument(contents)
|
|
if err != nil {
|
|
return store.Current(), err
|
|
}
|
|
settings.Path = store.path
|
|
settings.Loaded = true
|
|
store.setCurrent(settings)
|
|
return cloneSettings(settings), nil
|
|
}
|
|
|
|
// Save updates only the active profile and preserves all other profiles and
|
|
// application mappings. It validates before using an atomic replacement.
|
|
func (store *Store) Save(settings Settings) error {
|
|
return store.save(settings, "")
|
|
}
|
|
|
|
// LoadForApplication returns the selected application profile, or the global
|
|
// profile when no application settings have been saved.
|
|
func (store *Store) LoadForApplication(application string) (Settings, bool, error) {
|
|
store.ioMu.Lock()
|
|
defer store.ioMu.Unlock()
|
|
document, _, err := store.documentForSave()
|
|
if err != nil {
|
|
return Settings{}, false, err
|
|
}
|
|
global, err := effectiveSettings(document)
|
|
if err != nil {
|
|
return Settings{}, false, err
|
|
}
|
|
name, found := document.Applications[application]
|
|
if found {
|
|
applyProfile(&global, document.Profiles[name])
|
|
global.ActiveProfile = name
|
|
}
|
|
global.Path, global.Loaded = store.path, true
|
|
return global, found, nil
|
|
}
|
|
|
|
// SaveForApplication creates or updates a dedicated profile without changing
|
|
// the global profile or other application mappings.
|
|
func (store *Store) SaveForApplication(application string, settings Settings) error {
|
|
if application == "" || application != strings.TrimSpace(application) {
|
|
return errors.New("invalid application identifier")
|
|
}
|
|
if err := validateCommandValue("application identifier", application); err != nil {
|
|
return err
|
|
}
|
|
return store.save(settings, application)
|
|
}
|
|
|
|
func (store *Store) save(settings Settings, application string) error {
|
|
store.ioMu.Lock()
|
|
defer store.ioMu.Unlock()
|
|
if err := validate(settings); err != nil {
|
|
return err
|
|
}
|
|
document, original, err := store.documentForSave()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
global, err := effectiveSettings(document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profileName := document.ActiveProfile
|
|
if application == "" {
|
|
if settings.ActiveProfile != "" {
|
|
profileName = settings.ActiveProfile
|
|
}
|
|
if _, exists := document.Profiles[profileName]; !exists {
|
|
return fmt.Errorf("active profile %q does not exist", profileName)
|
|
}
|
|
} else {
|
|
profileName = document.Applications[application]
|
|
if profileName == "" {
|
|
hash := sha256.Sum256([]byte(application))
|
|
profileName = "application-" + hex.EncodeToString(hash[:])
|
|
}
|
|
if document.Applications == nil {
|
|
document.Applications = make(map[string]string)
|
|
}
|
|
document.Applications[application] = profileName
|
|
}
|
|
profile := profileFromSettings(settings)
|
|
if application != "" {
|
|
profile = applicationProfileDifference(global, settings)
|
|
}
|
|
document.Profiles[profileName] = profile
|
|
contents, err := encodeDocument(document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil {
|
|
return fmt.Errorf("create settings directory: %w", err)
|
|
}
|
|
temporary, err := os.CreateTemp(filepath.Dir(store.path), ".settings-*.toml")
|
|
if err != nil {
|
|
return fmt.Errorf("create temporary settings file: %w", err)
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
keepTemporary := false
|
|
defer func() {
|
|
if !keepTemporary {
|
|
_ = os.Remove(temporaryPath)
|
|
}
|
|
}()
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("restrict temporary settings file: %w", err)
|
|
}
|
|
if _, err := temporary.Write(contents); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("write temporary settings file: %w", err)
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("flush temporary settings file: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("close temporary settings file: %w", err)
|
|
}
|
|
if err := ensureUnchanged(store.path, original); err != nil {
|
|
return err
|
|
}
|
|
if err := store.rename(temporaryPath, store.path); err != nil {
|
|
return fmt.Errorf("replace settings file: %w", err)
|
|
}
|
|
keepTemporary = true
|
|
saved, err := effectiveSettings(document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
saved.Path = store.path
|
|
saved.Loaded = true
|
|
store.setCurrent(saved)
|
|
if err := syncDirectory(filepath.Dir(store.path)); err != nil {
|
|
return fmt.Errorf("flush settings directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (store *Store) documentForSave() (writableDocument, []byte, error) {
|
|
contents, err := readStoreFile(store.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return defaultDocument(), nil, nil
|
|
}
|
|
if err != nil {
|
|
return writableDocument{}, nil, err
|
|
}
|
|
document, _, err := decodeDocument(contents)
|
|
if err != nil {
|
|
return writableDocument{}, nil, fmt.Errorf("refuse to replace existing settings without a successful versioned load: %w", err)
|
|
}
|
|
return document, contents, nil
|
|
}
|
|
|
|
func ensureUnchanged(path string, original []byte) error {
|
|
current, err := readStoreFile(path)
|
|
if original == nil && errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("verify settings before replacement: %w", err)
|
|
}
|
|
if original == nil || !bytes.Equal(current, original) {
|
|
return errors.New("settings changed while being saved; no file was replaced")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readStoreFile(path string) ([]byte, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
contents, err := io.ReadAll(io.LimitReader(file, maxConfigSize+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read settings: %w", err)
|
|
}
|
|
if len(contents) > maxConfigSize {
|
|
return nil, fmt.Errorf("settings exceed %d bytes", maxConfigSize)
|
|
}
|
|
return contents, nil
|
|
}
|
|
|
|
func (store *Store) setCurrent(settings Settings) {
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
store.lastGood = cloneSettings(settings)
|
|
}
|
|
|
|
func syncDirectory(path string) error {
|
|
directory, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer directory.Close()
|
|
if err := directory.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) && !errors.Is(err, syscall.ENOTSUP) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|