Files
fenrir/internal/clipboard/persistence_test.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)
}
}