// SPDX-License-Identifier: GPL-3.0-or-later package trash import ( "errors" "os" "path/filepath" "strings" "testing" "unicode/utf8" ) // sandbox points XDG_DATA_HOME at a temporary tree, so the real Trash is // never touched. func sandbox(t *testing.T) string { t.Helper() h := t.TempDir() t.Setenv("HOME", h) t.Setenv("XDG_DATA_HOME", filepath.Join(h, "share")) t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("XDG_STATE_HOME", "") t.Setenv("XDG_CACHE_HOME", "") return h } func write(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestPutWritesBothParts(t *testing.T) { h := sandbox(t) src := filepath.Join(h, "dl", "old report.pdf") write(t, src, "pdf") entry, err := Put(src) if err != nil { t.Fatal(err) } if entry != "old report.pdf" { t.Errorf("entry = %q, want the base name", entry) } if _, err := os.Stat(src); !os.IsNotExist(err) { t.Error("the original is still in place") } if b, err := os.ReadFile(filepath.Join(Dir(), "files", entry)); err != nil || string(b) != "pdf" { t.Errorf("trashed content = %q, %v", b, err) } info, err := os.ReadFile(filepath.Join(Dir(), "info", entry+".trashinfo")) if err != nil { t.Fatal(err) } got := string(info) if !strings.HasPrefix(got, "[Trash Info]\n") { t.Errorf("trashinfo lacks its header:\n%s", got) } if !strings.Contains(got, "Path="+strings.ReplaceAll(src, " ", "%20")+"\n") { t.Errorf("Path is not the absolute percent-encoded original:\n%s", got) } // DeletionDate is local time with no offset: 19 characters, no Z, no +. for _, line := range strings.Split(got, "\n") { if v, ok := strings.CutPrefix(line, "DeletionDate="); ok { if len(v) != 19 || strings.ContainsAny(v, "Z+") { t.Errorf("DeletionDate = %q; want local time like 2026-04-23T16:04:23", v) } } } } func TestPutSuffixesOnCollision(t *testing.T) { h := sandbox(t) first := filepath.Join(h, "a", "x.pdf") second := filepath.Join(h, "b", "x.pdf") write(t, first, "one") write(t, second, "two") if _, err := Put(first); err != nil { t.Fatal(err) } entry, err := Put(second) if err != nil { t.Fatal(err) } if entry != "x_1.pdf" { t.Fatalf("second entry = %q, want x_1.pdf", entry) } if b, _ := os.ReadFile(filepath.Join(Dir(), "files", "x.pdf")); string(b) != "one" { t.Error("the first entry was overwritten") } if b, _ := os.ReadFile(filepath.Join(Dir(), "files", "x_1.pdf")); string(b) != "two" { t.Error("the second entry holds the wrong content") } info, err := os.ReadFile(filepath.Join(Dir(), "info", "x_1.pdf.trashinfo")) if err != nil { t.Fatal(err) } wantPath := "Path=" + strings.ReplaceAll(second, " ", "%20") + "\n" if !strings.Contains(string(info), wantPath) { t.Errorf("x_1.pdf.trashinfo does not point at its own original %q:\n%s", second, info) } } func TestRestoreRoundTrips(t *testing.T) { h := sandbox(t) src := filepath.Join(h, "dl", "zażółć gęślą.pdf") write(t, src, "polish") entry, err := Put(src) if err != nil { t.Fatal(err) } restored, err := Restore(entry) if err != nil { t.Fatal(err) } if restored != src { t.Errorf("restored to %q, want %q", restored, src) } if b, err := os.ReadFile(src); err != nil || string(b) != "polish" { t.Errorf("content after restore = %q, %v", b, err) } if _, err := os.Stat(filepath.Join(Dir(), "info", entry+".trashinfo")); !os.IsNotExist(err) { t.Error("the .trashinfo was left behind") } } func TestRestoreRefusesWhenTargetExists(t *testing.T) { h := sandbox(t) src := filepath.Join(h, "dl", "x.pdf") write(t, src, "one") entry, err := Put(src) if err != nil { t.Fatal(err) } write(t, src, "something new") if _, err := Restore(entry); err == nil { t.Fatal("Restore overwrote a file that had taken the original path") } if b, _ := os.ReadFile(src); string(b) != "something new" { t.Error("the file at the original path was modified") } if _, err := os.Stat(filepath.Join(Dir(), "files", entry)); err != nil { t.Error("the trash entry was consumed by a refused restore") } } // TestPutRefusesOtherFilesystem needs a second filesystem. /dev/shm is one on // Linux; the test skips where there is none. func TestPutRefusesOtherFilesystem(t *testing.T) { sandbox(t) other, err := os.MkdirTemp("/dev/shm", "krino-trash-") if err != nil { t.Skip("no second filesystem available:", err) } defer os.RemoveAll(other) src := filepath.Join(other, "x.pdf") write(t, src, "elsewhere") if _, err := Put(src); !errors.Is(err, ErrOtherFilesystem) { t.Fatalf("Put across filesystems: err = %v, want ErrOtherFilesystem", err) } if b, err := os.ReadFile(src); err != nil || string(b) != "elsewhere" { t.Errorf("the file was disturbed by a refused Put: %q, %v", b, err) } if entries, _ := os.ReadDir(filepath.Join(Dir(), "info")); len(entries) != 0 { t.Errorf("a refused Put left %d orphaned info files", len(entries)) } } // TestPutSkipsOrphanedFiles: a name already taken in files/ - left there // without its trashinfo - is not overwritten; Put takes the next free name // (review planapply F5). func TestPutSkipsOrphanedFiles(t *testing.T) { h := sandbox(t) orphan := filepath.Join(Dir(), "files", "a.pdf") write(t, orphan, "OLD TRASHED CONTENT") src := filepath.Join(h, "a.pdf") write(t, src, "new") entry, err := Put(src) if err != nil { t.Fatal(err) } if entry != "a_1.pdf" { t.Errorf("entry = %q, want a_1.pdf", entry) } if b, _ := os.ReadFile(orphan); string(b) != "OLD TRASHED CONTENT" { t.Errorf("the orphaned file was overwritten: %q", b) } } // TestPutTrashesLongNames: a name near the 255-byte limit still goes to the // Trash - its entry name is shortened so ".trashinfo" and a suffix fit - and // comes back under its full original name (review planapply F6). func TestPutTrashesLongNames(t *testing.T) { h := sandbox(t) name := strings.Repeat("ż", 123) + ".pdf" // 246 + 4 = 250 bytes src := filepath.Join(h, name) write(t, src, "long") entry, err := Put(src) if err != nil { t.Fatal(err) } if len(entry) > 239 || !strings.HasSuffix(entry, ".pdf") || !utf8.ValidString(entry) { t.Errorf("entry %q (%d bytes) does not fit, lost its extension, or split a character", entry, len(entry)) } restored, err := Restore(entry) if err != nil || restored != src { t.Errorf("Restore = %q, %v; want %q", restored, err, src) } } // TestInfoPath: the original path a trash entry records, and refusal of an // entry name that is not a plain name inside the Trash. func TestInfoPath(t *testing.T) { h := sandbox(t) src := filepath.Join(h, "dl", "a.pdf") write(t, src, "x") entry, err := Put(src) if err != nil { t.Fatal(err) } if p, err := InfoPath(entry); err != nil || p != src { t.Errorf("InfoPath = %q, %v; want %q", p, err, src) } if _, err := InfoPath("../x"); err == nil { t.Error("InfoPath accepted ../x") } }