package web import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // .fab download exit (v0.124.0 Part 3) — the containment guard, the post-stream cleanup and // the TTL sweep. The guard is NON-NEGOTIABLE (§9): only an expected-shaped basename resolved // strictly inside the staging exports dir is ever opened. func downloadTestServer(t *testing.T) (*Server, string) { t.Helper() s := testServer(t) s.cfg.Paths.DataDir = t.TempDir() dir := s.fabDownloadDir() if err := os.MkdirAll(dir, 0755); err != nil { t.Fatal(err) } return s, dir } func fetchDownload(s *Server, file string) *httptest.ResponseRecorder { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/export/download?file="+file, nil) s.apiExportDownloadFetch(rr, req) return rr } // Traversal guard: ../, absolute paths, separators and out-of-dir names must all be refused // with NO file opened; a legit staged bundle streams byte-exactly and is removed afterwards. // Red-proof: loosen the guard to raw prefix-matching → the `..` case FAILS (serves the decoy). func TestFabDownload_TraversalGuardAndCleanup(t *testing.T) { s, dir := downloadTestServer(t) // A decoy OUTSIDE the staging dir that a traversal would reach. decoy := filepath.Join(filepath.Dir(dir), "decoy.fab") if err := os.WriteFile(decoy, []byte("DECOY"), 0644); err != nil { t.Fatal(err) } // The legit staged bundle. want := []byte("FAB-BUNDLE-BYTES") if err := os.WriteFile(filepath.Join(dir, "actualbudget_20260713-080000.fab"), want, 0644); err != nil { t.Fatal(err) } refused := []string{ "..%2Fdecoy.fab", // ../decoy.fab "..%5Cdecoy.fab", // ..\decoy.fab "%2Fetc%2Fpasswd", // absolute path "sub%2Fx.fab", // separator "..", // bare traversal ".hidden.fab", // not the exporter's naming (leading dot) "x.txt", // not a .fab } for _, f := range refused { if rr := fetchDownload(s, f); rr.Code != http.StatusBadRequest { t.Errorf("file=%q: got %d, want 400", f, rr.Code) } } if body, err := os.ReadFile(decoy); err != nil || string(body) != "DECOY" { t.Fatal("the decoy outside the staging dir was touched") } // A legit-looking name that does not exist → 404 (never an open of anything else). if rr := fetchDownload(s, "nosuch_20260713-080000.fab"); rr.Code != http.StatusNotFound { t.Errorf("missing bundle: got %d, want 404", rr.Code) } // The legit bundle: streamed byte-exactly with attachment headers, then REMOVED. rr := fetchDownload(s, "actualbudget_20260713-080000.fab") if rr.Code != http.StatusOK { t.Fatalf("legit download: got %d (%s)", rr.Code, rr.Body.String()) } if rr.Body.String() != string(want) { t.Fatalf("streamed bytes differ from the staged bundle") } if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="actualbudget_20260713-080000.fab"`) { t.Errorf("Content-Disposition = %q", cd) } if _, err := os.Stat(filepath.Join(dir, "actualbudget_20260713-080000.fab")); !os.IsNotExist(err) { t.Error("staged bundle must be removed after a successful stream") } } // The download API inherits the post-claim auth: an unclaimed box answers 401 before any // handler runs (asserted per §7C even though it rides the existing RequireAuth). func TestFabDownload_UnauthenticatedRefused(t *testing.T) { s, _, _ := claimTestServer(t) mux := s.fullMux() rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/export/download?file=x_1.fab", nil)) if rr.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated download: got %d, want 401", rr.Code) } } // TTL sweep: an aged bundle (and exporter temp) is removed; a fresh bundle and non-bundle // files are kept. Time is injected — no clock dependency. func TestFabDownload_TTLSweep(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) mk := func(name string, age time.Duration) { p := filepath.Join(dir, name) if err := os.WriteFile(p, []byte("x"), 0644); err != nil { t.Fatal(err) } if err := os.Chtimes(p, now.Add(-age), now.Add(-age)); err != nil { t.Fatal(err) } } mk("old_20260713-000000.fab", 2*time.Hour) mk("stale.fab.tmp", 3*time.Hour) mk("fresh_20260713-115500.fab", 5*time.Minute) mk("unrelated.txt", 48*time.Hour) if n := sweepFabDownloads(dir, now, time.Hour, nil); n != 2 { t.Fatalf("sweep removed %d entries, want 2 (the aged bundle + temp)", n) } for name, wantGone := range map[string]bool{ "old_20260713-000000.fab": true, "stale.fab.tmp": true, "fresh_20260713-115500.fab": false, "unrelated.txt": false, } { _, err := os.Stat(filepath.Join(dir, name)) gone := os.IsNotExist(err) if gone != wantGone { t.Errorf("%s: gone=%v want %v", name, gone, wantGone) } } }