Files
ARTEX/traffic/reclaim_test.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

258 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package traffic
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// bulkRecord fills the index with inline bodies — the ones that actually make
// index.sqlite grow. Binary content type keeps them out of the full-text index so
// the test stays fast; the FTS side is covered by TestReclaimMergesFTSTombstones.
func bulkRecord(tr *Traffic, host string, n, size int) {
body := []byte(strings.Repeat("A", size))
for i := 0; i < n; i++ {
tr.record(newFlow(host, "GET", fmt.Sprintf("/blob/%d", i), nil, body,
withRespType("application/octet-stream")))
}
}
func TestNewIndexEnablesIncrementalVacuum(t *testing.T) {
tr, _ := openTraffic(t)
if !tr.incrementalVacuum {
t.Fatal("newly created index database did not enable incremental vacuum")
}
var mode int
if err := tr.DB().QueryRow(`PRAGMA auto_vacuum`).Scan(&mode); err != nil {
t.Fatal(err)
}
if mode != autoVacuumIncremental {
t.Fatalf("auto_vacuum=%d, want %d", mode, autoVacuumIncremental)
}
}
// TestDeleteReclaimsIndexSpace is the regression: deleting traffic used to leave
// index.sqlite at its high-water mark forever, because SQLite only chains freed
// pages onto its freelist and nothing ever returned them to the filesystem.
func TestDeleteReclaimsIndexSpace(t *testing.T) {
tr, _ := openTraffic(t)
const host = "bulk.example.com"
// 30 × 200KB stays under maxInlineBody, so every body lands in the database
// itself rather than the blob store — that is where the growth was invisible.
bulkRecord(tr, host, 30, 200*1024)
grown := tr.indexBytes()
if grown < 5<<20 {
t.Fatalf("index is only %d bytes, not enough sample to verify reclamation", grown)
}
if n, err := tr.DeleteHostsExact([]string{host}); err != nil || n != 30 {
t.Fatalf("DeleteHostsExact=(%d,%v), want (30,nil)", n, err)
}
tr.reaping.Wait() // reclamation proceeds in background chunks
after := tr.indexBytes()
if after > grown/4 {
t.Fatalf("index still occupies %d bytes after deletion (was %d before); space was not returned to the filesystem", after, grown)
}
// A handful of pages incremental_vacuum could not move to the end of the file
// is a normal residual; the ~1500 that the deletion freed must be gone.
var free int
if err := tr.DB().QueryRow(`PRAGMA freelist_count`).Scan(&free); err != nil {
t.Fatal(err)
}
if free > 64 {
t.Fatalf("%d free pages still not reclaimed", free)
}
}
// TestReclaimMergesFTSTombstones covers the second half of the leak: ex_fts is a
// contentless_delete index, so a DELETE only writes tombstones. Without a merge
// the index keeps growing on every deletion — deleting traffic made it bigger.
func TestReclaimMergesFTSTombstones(t *testing.T) {
tr, _ := openTraffic(t)
if !tr.fts {
t.Skip("driver does not have FTS5 enabled")
}
// Deleted in batches, which is what leaves tombstones spread over many
// segments rather than emptying the index in one shot.
for round := 0; round < 4; round++ {
host := fmt.Sprintf("fts%d.example.com", round)
for i := 0; i < 20; i++ {
tr.record(newFlow(host, "GET", fmt.Sprintf("/p/%d", i), nil,
[]byte(strings.Repeat("secret token chinese body padding ", 200))))
}
if _, err := tr.DeleteHostsExact([]string{host}); err != nil {
t.Fatal(err)
}
tr.reaping.Wait()
}
var exchanges, segments int
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&exchanges); err != nil {
t.Fatal(err)
}
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM ex_fts_data`).Scan(&segments); err != nil {
t.Fatal(err)
}
if exchanges != 0 {
t.Fatalf("%d exchanges still left", exchanges)
}
// A fully merged, empty contentless index keeps only its structure rows.
if segments > 8 {
t.Fatalf("full-text index has %d leftover segment rows; tombstones were not merged and reclaimed", segments)
}
}
// TestReclaimOnLegacyIndexIsHarmless covers installs created before
// auto_vacuum=incremental became the default: incremental_vacuum is a silent
// no-op there, so reclamation must report the situation and finish rather than
// spin or fail. Only a full compaction can convert such a file.
func TestReclaimOnLegacyIndexIsHarmless(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "_index"), 0o755); err != nil {
t.Fatal(err)
}
// Create the tables first, with auto_vacuum left at its default 0 — exactly the
// shape Open used to leave behind.
legacy, err := sql.Open("sqlite", filepath.Join(dir, "_index", "index.sqlite"))
if err != nil {
t.Fatal(err)
}
if _, err := legacy.Exec(indexSchema); err != nil {
t.Fatal(err)
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
tr, err := Open(dir, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tr.Close() })
if tr.incrementalVacuum {
t.Fatal("legacy database must not report incremental vacuum as enabled")
}
const host = "legacy.example.com"
bulkRecord(tr, host, 8, 200*1024)
if n, err := tr.DeleteHostsExact([]string{host}); err != nil || n != 8 {
t.Fatalf("DeleteHostsExact=(%d,%v), want (8,nil)", n, err)
}
tr.reaping.Wait() // must converge, not get stuck inside the budget
// The freelist stays populated: that is the whole reason a compaction entry
// point is needed for pre-existing databases.
var free int
if err := tr.DB().QueryRow(`PRAGMA freelist_count`).Scan(&free); err != nil {
t.Fatal(err)
}
if free == 0 {
t.Fatal("the legacy database actually reclaimed free pages; the test did not really build a legacy database")
}
}
// TestDeleteAllPurgesAndCompacts covers the page's clear-everything action: it
// must leave nothing behind — including host directories the index no longer
// knows about — and it must hand the index space back, since an emptied index is
// the one moment a full rewrite is cheap.
func TestDeleteAllPurgesAndCompacts(t *testing.T) {
tr, dir := openTraffic(t)
bulkRecord(tr, "a.example.com", 10, 200*1024)
bulkRecord(tr, "b.example.com", 10, 200*1024)
// A text body so the full-text index has real content, and a spilled one so a
// blob exists to collect.
tr.record(newFlow("c.example.com", "GET", "/page", nil, []byte(strings.Repeat("secret-token ", 500))))
tr.record(newFlow("c.example.com", "GET", "/big", nil,
[]byte(strings.Repeat("B", maxInlineBody+1024)), withRespType("application/sql")))
// An orphaned legacy directory: no index row points at it, so only a
// clear-everything should take it.
orphan := filepath.Join(dir, "orphan.example.com")
if err := os.MkdirAll(orphan, 0o755); err != nil {
t.Fatal(err)
}
grown := tr.indexBytes()
if grown < 5<<20 {
t.Fatalf("index is only %d bytes, not enough sample", grown)
}
deleted, reclaimed, err := tr.DeleteAll()
if err != nil {
t.Fatalf("DeleteAll: %v", err)
}
if deleted != 22 {
t.Fatalf("deleted=%d, want 22", deleted)
}
tr.reaping.Wait()
if reclaimed < grown/2 {
t.Fatalf("only %d bytes reclaimed (index was %d before deletion)", reclaimed, grown)
}
if after := tr.indexBytes(); after > grown/8 {
t.Fatalf("index still occupies %d bytes after the purge (was %d before)", after, grown)
}
for _, q := range []string{
`SELECT COUNT(*) FROM exchanges`,
`SELECT COUNT(*) FROM exchange_bodies`,
`SELECT COUNT(*) FROM blob_refs`,
} {
var c int
if err := tr.DB().QueryRow(q).Scan(&c); err != nil {
t.Fatal(err)
}
if c != 0 {
t.Fatalf("%s = %d, want 0", q, c)
}
}
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
t.Fatalf("orphaned legacy host directory not cleaned up: %v", err)
}
// Recording must keep working against the freshly rewritten file.
tr.record(newFlow("d.example.com", "GET", "/after", nil, []byte("recording still works after the purge")))
if n, err := tr.Count(); err != nil || n != 1 {
t.Fatalf("after the purge Count=(%d,%v), want (1,nil)", n, err)
}
}
// TestDeleteAllConvertsLegacyIndex is why the purge compacts rather than just
// deleting: auto_vacuum cannot be switched on after the fact except through a
// VACUUM, and an emptied index is the cheapest place to pay for one. After this,
// ordinary deletions reclaim space on their own.
func TestDeleteAllConvertsLegacyIndex(t *testing.T) {
dir := t.TempDir()
old := openLegacyIndex(t, dir)
if err := old.Close(); err != nil {
t.Fatal(err)
}
tr, err := Open(dir, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tr.Close() })
if tr.incrementalVacuum {
t.Fatal("legacy database must not report incremental vacuum as enabled")
}
bulkRecord(tr, "legacy.example.com", 10, 200*1024)
if _, _, err := tr.DeleteAll(); err != nil {
t.Fatalf("DeleteAll: %v", err)
}
if !tr.incrementalVacuum {
t.Fatal("legacy database not converted to incremental vacuum mode after the purge")
}
// The converted database now reclaims on an ordinary host deletion.
bulkRecord(tr, "again.example.com", 10, 200*1024)
grown := tr.indexBytes()
if _, err := tr.DeleteHostsExact([]string{"again.example.com"}); err != nil {
t.Fatal(err)
}
tr.reaping.Wait()
if after := tr.indexBytes(); after > grown/4 {
t.Fatalf("ordinary deletion still did not reclaim after conversion: %d bytes (was %d before)", after, grown)
}
}