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