// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package spellcheck import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "syscall" "time" ) func Exceptions_path(home string) string { return filepath.Join(home, ".local/share/stormux/fenrirscreenreader/spelling/exceptions.json") } func load_exceptions(path string) (map[string]string, error) { words := make(map[string]string) file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return words, nil } if err != nil { return nil, err } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) if err != nil { return nil, err } if len(data) > 1<<20 { return nil, fmt.Errorf("exceptions file exceeds 1 MiB") } var entries []string if err := json.Unmarshal(data, &entries); err != nil { return nil, err } for _, word := range entries { if !valid_word(word) { return nil, fmt.Errorf("invalid exceptions file") } words[strings.ToLower(word)] = word } return words, nil } func exception_list(words map[string]string) []string { entries := make([]string, 0, len(words)) for _, word := range words { entries = append(entries, word) } sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i]) < strings.ToLower(entries[j]) }) return entries } // Each edit reloads the latest list while holding a stable, per-file lock. // Atomic replacement alone cannot protect against another reader's stale copy. func update_exceptions(ctx context.Context, path string, operation Operation, word string) (map[string]string, error) { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return nil, err } lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600) if err != nil { return nil, err } defer lock.Close() info, err := lock.Stat() if err != nil { return nil, err } if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) { return nil, fmt.Errorf("exceptions lock must be private and owned by the current account") } waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() for { if err := waitCtx.Err(); err != nil { return nil, err } err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if err == nil { break } if !errors.Is(err, syscall.EWOULDBLOCK) { return nil, err } timer := time.NewTimer(10 * time.Millisecond) select { case <-waitCtx.Done(): timer.Stop() return nil, waitCtx.Err() case <-timer.C: } } words, err := load_exceptions(path) if err != nil { return nil, err } key := strings.ToLower(word) if operation == Add { words[key] = word } else { delete(words, key) } if err := save_exceptions(path, words); err != nil { return nil, err } return words, nil } func save_exceptions(path string, words map[string]string) error { data, err := json.MarshalIndent(exception_list(words), "", " ") if err != nil { return err } if len(data) > 1<<20 { return fmt.Errorf("exceptions file exceeds 1 MiB") } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0700); err != nil { return err } file, err := os.CreateTemp(dir, ".exceptions-*.json") if err != nil { return err } defer os.Remove(file.Name()) if _, err := file.Write(append(data, '\n')); err != nil { file.Close() return err } if err := file.Sync(); err != nil { file.Close() return err } if err := file.Close(); err != nil { return err } return os.Rename(file.Name(), path) }