Files

148 lines
3.5 KiB
Go

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