228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package clipboard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPersistentHistoryRoundTripResizeAndPrivateModes(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "private", "terminal.json")
|
|
history := New_history(3)
|
|
for _, text := range []string{"old", "é界\nsecond line", "🎃"} {
|
|
if err := history.Add(text); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
history.Move(1)
|
|
store := New_persistence(path, false)
|
|
store.Enable(history.Snapshot(), 3)
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for file, mode := range map[string]os.FileMode{path: 0600, filepath.Dir(path): 0700} {
|
|
info, err := os.Stat(file)
|
|
if err != nil || info.Mode().Perm() != mode {
|
|
t.Fatal(info, err)
|
|
}
|
|
}
|
|
store = New_persistence(path, true)
|
|
if store.LoadErr != nil {
|
|
t.Fatal(store.LoadErr)
|
|
}
|
|
restored := New_history(2)
|
|
if err := restored.Restore(store.Initial); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := restored.Current(); got != "é界\nsecond line" || !restored.At_last() {
|
|
t.Fatal(got)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored.Resize(1)
|
|
if got, _ := restored.Current(); got != "🎃" {
|
|
t.Fatal(got)
|
|
}
|
|
restored.Resize(4)
|
|
restored.Add("new")
|
|
if !reflect.DeepEqual(restored.Snapshot().Entries, []string{"new", "🎃"}) {
|
|
t.Fatal(restored.Snapshot())
|
|
}
|
|
}
|
|
|
|
func TestPersistenceDisabledDoesNotReadOrCreateAndModesAreSeparate(t *testing.T) {
|
|
home := t.TempDir()
|
|
terminalPath := History_path(home, true)
|
|
if !strings.HasPrefix(terminalPath, home+string(os.PathSeparator)) || History_path(home, false) != "/var/lib/fenrir/clipboard/tty.json" || terminalPath == History_path(home, false) {
|
|
t.Fatal(terminalPath, History_path(home, false))
|
|
}
|
|
store := New_persistence(terminalPath, false)
|
|
if store.LoadErr != nil || len(store.Initial.Entries) != 0 {
|
|
t.Fatal(store)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Dir(terminalPath)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("disabled persistence created state", err)
|
|
}
|
|
// A pre-existing malformed file is also ignored while disabled.
|
|
path := filepath.Join(home, "malformed.json")
|
|
os.WriteFile(path, []byte("not json"), 0600)
|
|
store = New_persistence(path, false)
|
|
if store.LoadErr != nil {
|
|
t.Fatal("disabled persistence read a file", store.LoadErr)
|
|
}
|
|
store.Close()
|
|
}
|
|
|
|
func TestPersistenceClearWinsOverQueuedWritesAndQuitFlushes(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "private", "clipboard.json")
|
|
state := Snapshot{Version: 1, Entries: []string{"first"}}
|
|
store := New_persistence(path, false)
|
|
store.Enable(state, 200)
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
if _, err := os.Stat(path); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("initial write did not finish")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
for range 10 {
|
|
store.Add(strings.Repeat("x", 1<<20), 200)
|
|
}
|
|
store.Clear()
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("old write resurrected cleared history", err)
|
|
}
|
|
store = New_persistence(path, false)
|
|
store.Enable(state, 200)
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := load_history(path)
|
|
if err != nil || !reflect.DeepEqual(got, state) {
|
|
t.Fatal("quit did not flush newest state", got, err)
|
|
}
|
|
}
|
|
|
|
func TestPersistenceRejectsCorruptUnsafeAndUnboundedFiles(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "private")
|
|
if err := os.Mkdir(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(root, "history.json")
|
|
for _, data := range [][]byte{
|
|
[]byte("not JSON"), []byte{0xff},
|
|
[]byte(`{"version":2,"entries":[],"selected":0}`),
|
|
[]byte(`{"version":1,"entries":["word"],"selected":2}`),
|
|
[]byte(`{"version":1,"entries":[],"selected":-1}`),
|
|
} {
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := load_history(path); err == nil {
|
|
t.Fatal("invalid snapshot accepted")
|
|
}
|
|
}
|
|
state := Snapshot{Version: 1, Entries: []string{"word"}}
|
|
if err := save_history(path, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := load_history(path); err == nil {
|
|
t.Fatal("public file accepted")
|
|
}
|
|
os.Chmod(path, 0600)
|
|
file, err := os.OpenFile(path, os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file.Truncate(maxHistoryBytes + 1)
|
|
file.Close()
|
|
if _, err := load_history(path); err == nil {
|
|
t.Fatal("oversized file accepted")
|
|
}
|
|
if err := save_history(path, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := os.ReadFile(path)
|
|
if err := save_history(path, Snapshot{Version: 1, Entries: []string{strings.Repeat("x", MaxBytes+1)}}); err == nil {
|
|
t.Fatal("oversized entry saved")
|
|
}
|
|
after, _ := os.ReadFile(path)
|
|
if string(before) != string(after) {
|
|
t.Fatal("rejected save replaced history")
|
|
}
|
|
link := filepath.Join(root, "link.json")
|
|
if err := os.Symlink(path, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := load_history(link); err == nil {
|
|
t.Fatal("symlink read")
|
|
}
|
|
if err := save_history(link, state); err == nil {
|
|
t.Fatal("symlink replaced")
|
|
}
|
|
fifo := filepath.Join(root, "fifo")
|
|
if err := syscall.Mkfifo(fifo, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := load_history(fifo); err == nil {
|
|
t.Fatal("FIFO accepted")
|
|
}
|
|
public := filepath.Join(root, "public")
|
|
os.Mkdir(public, 0755)
|
|
if err := save_history(filepath.Join(public, "history.json"), state); err == nil {
|
|
t.Fatal("public directory accepted")
|
|
}
|
|
var decoded Snapshot
|
|
if err := json.Unmarshal(before, &decoded); err != nil || !reflect.DeepEqual(state, decoded) {
|
|
t.Fatal(decoded, err)
|
|
}
|
|
}
|
|
|
|
func TestClearRemovesTemporaryHistoryLeftByInterruptedWrite(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "private")
|
|
if err := os.Mkdir(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(root, "terminal.json")
|
|
stale := filepath.Join(root, ".terminal.json-interrupted.tmp")
|
|
other := filepath.Join(root, ".other.json-interrupted.tmp")
|
|
for _, name := range []string{stale, other} {
|
|
if err := os.WriteFile(name, []byte("old clipboard text"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
store := New_persistence(path, false)
|
|
store.Clear()
|
|
if err := store.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("clear retained interrupted write", err)
|
|
}
|
|
if _, err := os.Stat(other); err != nil {
|
|
t.Fatal("clear removed another history's file", err)
|
|
}
|
|
}
|