243 lines
6.6 KiB
Go
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
|
|
}
|