// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package clipboard import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "syscall" "time" "unicode/utf8" ) const maxHistoryBytes = 64 << 20 // Snapshot preserves newest-first order and the selected history entry. type Snapshot struct { Version int `json:"version"` Entries []string `json:"entries"` Selected int `json:"selected"` } func History_path(home string, terminalMode bool) string { if terminalMode { return filepath.Join(home, ".local/state/fenrir/clipboard/terminal.json") } return "/var/lib/fenrir/clipboard/tty.json" } func (h *History) Snapshot() Snapshot { return Snapshot{Version: 1, Entries: append([]string{}, h.entries...), Selected: h.index} } func validate_snapshot(state Snapshot) error { if state.Version != 1 || len(state.Entries) > 10000 || state.Selected < 0 || (len(state.Entries) == 0 && state.Selected != 0) || (len(state.Entries) > 0 && state.Selected >= len(state.Entries)) { return fmt.Errorf("invalid clipboard history") } total := 0 for _, text := range state.Entries { if err := Validate_text(text); err != nil { return err } total += len(text) if total > maxHistoryBytes { return fmt.Errorf("clipboard history exceeds 64 MiB") } } return nil } func (h *History) Restore(state Snapshot) error { if err := validate_snapshot(state); err != nil { return err } h.entries = append([]string(nil), state.Entries[:min(len(state.Entries), h.limit)]...) h.index = min(state.Selected, max(0, len(h.entries)-1)) return nil } func (h *History) Resize(limit int) { h.limit = max(1, limit) h.entries = h.entries[:min(len(h.entries), h.limit)] h.index = min(h.index, max(0, len(h.entries)-1)) } // A stable lock inode serializes only individual transactions. Closing it // releases ownership; another reader can immediately read, update or clear. func acquire_history(path string, create bool) (*os.File, error) { if err := private_directory(filepath.Dir(path), create); err != nil { if !create && errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } file, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600) if err != nil { return nil, err } info, err := file.Stat() if err == nil && (!info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid())) { err = fmt.Errorf("clipboard history lock must be private and owned by the current account") } deadline := time.Now().Add(2 * time.Second) for err == nil { err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if !errors.Is(err, syscall.EWOULDBLOCK) || time.Now().After(deadline) { break } time.Sleep(10 * time.Millisecond) err = nil } if err == nil { err = remove_temporary_history(path) } if err != nil { file.Close() return nil, err } return file, nil } func private_directory(path string, create bool) error { if create { if err := os.MkdirAll(path, 0700); err != nil { return err } } info, err := os.Lstat(path) if err != nil { return err } if !info.IsDir() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) { return fmt.Errorf("clipboard history directory must be private and owned by the current account") } return nil } func load_history(path string) (Snapshot, error) { empty := Snapshot{Version: 1} if err := private_directory(filepath.Dir(path), false); err != nil { if errors.Is(err, os.ErrNotExist) { return empty, nil } return empty, err } file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) if errors.Is(err, os.ErrNotExist) { return empty, nil } if err != nil { return empty, err } defer file.Close() info, err := file.Stat() if err != nil { return empty, err } if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) { return empty, fmt.Errorf("clipboard history must be a private regular file owned by the current account") } data, err := io.ReadAll(io.LimitReader(file, maxHistoryBytes+1)) if err != nil { return empty, err } if len(data) > maxHistoryBytes || !utf8.Valid(data) { return empty, fmt.Errorf("invalid or oversized clipboard history") } var state Snapshot if err := json.Unmarshal(data, &state); err != nil { return empty, fmt.Errorf("invalid clipboard history JSON") } if err := validate_snapshot(state); err != nil { return empty, err } return state, nil } func save_history(path string, state Snapshot) error { if err := validate_snapshot(state); err != nil { return err } data, err := json.Marshal(state) if err != nil { return err } if len(data) > maxHistoryBytes { return fmt.Errorf("clipboard history exceeds 64 MiB") } if err := private_directory(filepath.Dir(path), true); err != nil { return err } // Do not replace symlinks, devices or directories supplied at the destination. if info, err := os.Lstat(path); err == nil { if !info.Mode().IsRegular() { return fmt.Errorf("clipboard history must be a regular file") } } else if !errors.Is(err, os.ErrNotExist) { return err } file, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") if err != nil { return err } defer os.Remove(file.Name()) _, writeErr := file.Write(data) err = errors.Join(writeErr, file.Sync(), file.Close()) if err != nil { return err } if err := os.Rename(file.Name(), path); err != nil { return err } return sync_directory(filepath.Dir(path)) } func remove_history(path string) error { if err := private_directory(filepath.Dir(path), false); err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } err := os.Remove(path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } return sync_directory(filepath.Dir(path)) } func sync_directory(path string) error { dir, err := os.Open(path) if err != nil { return err } return errors.Join(dir.Sync(), dir.Close()) } // A killed process can leave an atomic-write temporary file. Only clean this // history's own files after acquiring its lock, including when clearing it. func remove_temporary_history(path string) error { entries, err := os.ReadDir(filepath.Dir(path)) if err != nil { return err } for _, entry := range entries { if !strings.HasPrefix(entry.Name(), "."+filepath.Base(path)+"-") || !strings.HasSuffix(entry.Name(), ".tmp") || !entry.Type().IsRegular() { continue } if err := os.Remove(filepath.Join(filepath.Dir(path), entry.Name())); err != nil { return err } } return nil }