Files
gnoma/internal/tui/paste_image_test.go
vikingowl bd41d76e32 refactor(tui): store pasted images in user cache, not project workdir
Ctrl+V image paste used to write the file to .gnoma/pasted_image_*.png
under the project root, which polluted the workdir and risked
committing screenshots that may contain sensitive content.

Now writes to os.UserCacheDir() / gnoma / pasted-images/ (XDG cache
on Linux, ~/Library/Caches on macOS, %LocalAppData% on Windows).
The directory is created at 0700 and files at 0600 since pasted
content can be sensitive.

Each paste prunes entries older than 2 hours best-effort, so the
cache doesn't accumulate across sessions. The 2h window safely
covers any single turn including provider retries and slow
subprocess CLIs that need the file to still exist on disk when
they ingest the path.

.gitignore: cover the legacy `.gnoma/pasted_image_*` location for
old checkouts; add log.txt and codex_out.jsonl which were tracked
as runtime artifacts during the recent work.

Tests cover cache-path placement, restrictive perms on both the
directory and the file, the no-pollution-of-cwd invariant, and the
prune behavior (stale removed, fresh kept, missing dir no-op).
2026-05-22 11:56:04 +02:00

103 lines
2.9 KiB
Go

package tui
import (
"os"
"path/filepath"
"testing"
"time"
)
// stagePastedImageCache redirects os.UserCacheDir() to a temp dir by
// overriding XDG_CACHE_HOME. Returns the resolved cache root.
func stagePastedImageCache(t *testing.T) string {
t.Helper()
root := t.TempDir()
t.Setenv("XDG_CACHE_HOME", root)
return filepath.Join(root, "gnoma", "pasted-images")
}
func TestStorePastedImage_WritesToUserCacheWithRestrictivePerms(t *testing.T) {
cacheDir := stagePastedImageCache(t)
path, err := storePastedImage([]byte("png-bytes"), ".png")
if err != nil {
t.Fatalf("storePastedImage: %v", err)
}
if filepath.Dir(path) != cacheDir {
t.Errorf("path dir = %q, want %q", filepath.Dir(path), cacheDir)
}
if filepath.Ext(path) != ".png" {
t.Errorf("path ext = %q, want .png", filepath.Ext(path))
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Errorf("file mode = %o, want 0600", mode)
}
if dirInfo, _ := os.Stat(cacheDir); dirInfo != nil {
if mode := dirInfo.Mode().Perm(); mode != 0o700 {
t.Errorf("dir mode = %o, want 0700", mode)
}
}
}
func TestStorePastedImage_DoesNotPolluteProjectRoot(t *testing.T) {
// Make sure the cache dir lookup doesn't fall back to cwd / the
// project root for any reason. Stage XDG_CACHE_HOME and verify
// the returned path is under it, not under cwd.
cacheRoot := t.TempDir()
t.Setenv("XDG_CACHE_HOME", cacheRoot)
cwd, _ := os.Getwd()
path, err := storePastedImage([]byte("x"), ".png")
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(cwd, path)
if err == nil && !filepath.IsAbs(rel) && rel[0] != '.' {
// path is inside cwd — that would mean we polluted the workdir
t.Errorf("storePastedImage wrote under cwd at %q", path)
}
}
func TestPruneStalePastedImages_RemovesOldKeepsFresh(t *testing.T) {
cacheDir := stagePastedImageCache(t)
// Manually create one stale + one fresh file (mtime via os.Chtimes).
stale := filepath.Join(cacheDir, "pasted_image_stale.png")
fresh := filepath.Join(cacheDir, "pasted_image_fresh.png")
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(stale, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fresh, []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-pastedImageStaleAfter - time.Minute)
if err := os.Chtimes(stale, old, old); err != nil {
t.Fatal(err)
}
pruneStalePastedImages(cacheDir)
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale file should be pruned, stat err = %v", err)
}
if _, err := os.Stat(fresh); err != nil {
t.Errorf("fresh file should survive, stat err = %v", err)
}
}
func TestPruneStalePastedImages_MissingDirIsNoOp(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("prune panicked on missing dir: %v", r)
}
}()
pruneStalePastedImages(filepath.Join(t.TempDir(), "does", "not", "exist"))
}