Files

243 lines
6.6 KiB
Go

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