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