Files
silo-server/internal/diagnostics/bundle_test.go
T
Quick104andClaude Fable 5 2d5d4980de fix(diagnostics): address round-2 review findings on PR #445
- settings.go: cap the parsed cleanup interval at 7 days before converting to
  time.Duration so a huge configured value can't overflow int64 nanoseconds and
  wrap into a tiny/negative interval; add boundary tests.
- settings.go: propagate genuine settings read failures from LoadSettings
  (missing/empty -> default, error -> fail) so a transient DB error surfaces
  retryably instead of silently reporting uploads disabled or wrong quotas.
- bundle.go: validate non-manifest bundle entries while streaming with bounded
  memory -- device.json and crash/*.json must be a single JSON object,
  logs.jsonl/breadcrumbs.jsonl must be newline-delimited JSON objects with a
  per-line byte cap (new contract.MaxLogLineBytes); binary members stay opaque.
- diagnostics upload handler: extend the read deadline per-route via
  http.ResponseController.SetReadDeadline (10m) so slow mobile uploads of large
  bundles aren't cut off by the shared 30s server ReadTimeout.
- web admin download: request the ?proxy=1 streaming path directly so downloads
  work when S3Private is only server-reachable and errors can surface in-page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 11:29:02 -04:00

403 lines
10 KiB
Go

package diagnostics
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"errors"
"io"
"strings"
"testing"
)
type testArchiveEntry struct {
name string
body []byte
typeflag byte
}
func TestValidateBundle(t *testing.T) {
validEntries := []testArchiveEntry{
{name: "manifest.json", body: []byte(`{"schema_version":1}`)},
{name: "logs.jsonl", body: []byte("{}\n")},
}
tests := []struct {
name string
entries []testArchiveEntry
limits BundleLimits
mutate func([]byte) []byte
wantErr error
wantEntries string
}{
{
name: "happy path",
entries: validEntries,
},
{
name: "wrong first entry",
entries: []testArchiveEntry{
{name: "device.json", body: []byte(`{}`)},
{name: "manifest.json", body: []byte(`{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "disallowed name",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "extra.txt", body: []byte("nope")},
},
wantErr: ErrInvalidBundle,
},
{
name: "traversal name",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "../logs.jsonl", body: []byte("nope")},
},
wantErr: ErrInvalidBundle,
},
{
name: "ratio bomb",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
// Valid newline-delimited JSON objects that still compress far
// beyond the ratio limit, so the ratio guard trips before the
// content validator can pass judgement on every line.
{name: "logs.jsonl", body: bytes.Repeat([]byte("{}\n"), 400*1024)},
},
limits: BundleLimits{
MaxUncompressedBytes: 4 * 1024 * 1024,
},
wantErr: ErrCompressionRatio,
},
{
name: "rejects malformed device.json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`{"identity": }`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects non-object device.json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`[1,2,3]`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects trailing data in device.json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`{}{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects malformed logs.jsonl line",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", body: []byte("{}\nnot json\n")},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects non-object logs.jsonl line",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", body: []byte("{}\n[1,2]\n")},
},
wantErr: ErrInvalidBundle,
},
{
name: "accepts well-formed device.json and crash json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`{"identity":{"model":"Pixel"}}` + "\n")},
{name: "logs.jsonl", body: []byte(`{"lvl":"I","msg":"hi"}` + "\n")},
{name: "crash/summary.json", body: []byte(`{"summary":"boom"}`)},
{name: "crash/tombstone.pb", body: []byte{0x00, 0x01, 0x02, 0x03}},
},
wantEntries: "manifest.json,device.json,logs.jsonl,crash/summary.json,crash/tombstone.pb",
},
{
name: "over count",
entries: append([]testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
}, repeatedEntries("logs.jsonl", 16)...),
wantErr: ErrTooManyEntries,
},
{
name: "oversize",
entries: validEntries,
limits: BundleLimits{MaxCompressedBytes: 64},
wantErr: ErrCompressedTooLarge,
},
{
name: "truncated gzip",
entries: validEntries,
mutate: func(raw []byte) []byte {
return raw[:len(raw)-8]
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects trailing tar data inside gzip stream",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, []byte("hidden"))
},
wantErr: ErrInvalidBundle,
},
{
name: "accepts tar record padding",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, make([]byte, 8*1024))
},
},
{
name: "rejects nonzero byte inside trailing padding",
entries: validEntries,
mutate: func([]byte) []byte {
padding := make([]byte, 8*1024)
padding[len(padding)-1] = 1
return buildTestArchiveWithPostTarPayload(t, validEntries, padding)
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects excessive trailing padding",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, make([]byte, maxTarTrailingPaddingBytes+1))
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects concatenated gzip member",
entries: validEntries,
mutate: func(raw []byte) []byte {
return append(raw, buildTestGzipMember(t, bytes.Repeat([]byte{0}, 1024))...)
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects symlinks",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", typeflag: tar.TypeSymlink},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects padded entry name",
entries: []testArchiveEntry{
{name: "manifest.json ", body: []byte(`{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects pax extension records",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchivePAX(t)
},
wantErr: ErrInvalidBundle,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := buildTestArchive(t, tt.entries)
if tt.mutate != nil {
raw = tt.mutate(raw)
}
info, err := ValidateBundle(bytes.NewReader(raw), tt.limits)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateBundle error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("ValidateBundle error = %v", err)
}
if info.CompressedBytes != int64(len(raw)) {
t.Fatalf("compressed bytes = %d, want %d", info.CompressedBytes, len(raw))
}
sum := sha256.Sum256(raw)
if info.SHA256 != fmtHex(sum[:]) {
t.Fatalf("sha256 = %s, want %s", info.SHA256, fmtHex(sum[:]))
}
wantEntries := tt.wantEntries
if wantEntries == "" {
wantEntries = "manifest.json,logs.jsonl"
}
if got := strings.Join(info.Entries, ","); got != wantEntries {
t.Fatalf("entries = %s, want %s", got, wantEntries)
}
gzr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("open gzip for uncompressed size: %v", err)
}
wantUncompressed, err := io.Copy(io.Discard, gzr)
if err != nil {
t.Fatalf("measure uncompressed size: %v", err)
}
if info.UncompressedBytes != wantUncompressed {
t.Fatalf("uncompressed bytes = %d, want %d", info.UncompressedBytes, wantUncompressed)
}
})
}
}
func repeatedEntries(name string, count int) []testArchiveEntry {
entries := make([]testArchiveEntry, 0, count)
for i := 0; i < count; i++ {
entries = append(entries, testArchiveEntry{name: name, body: []byte("{}\n")})
}
return entries
}
func buildTestArchive(t *testing.T, entries []testArchiveEntry) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
hdr := &tar.Header{
Name: entry.name,
Mode: 0o600,
Size: int64(len(entry.body)),
Typeflag: typeflag,
}
if typeflag != tar.TypeReg && typeflag != tar.TypeRegA {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if hdr.Size > 0 {
if _, err := tw.Write(entry.body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func buildTestArchiveWithPostTarPayload(t *testing.T, entries []testArchiveEntry, trailing []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
hdr := &tar.Header{
Name: entry.name,
Mode: 0o600,
Size: int64(len(entry.body)),
Typeflag: typeflag,
}
if typeflag != tar.TypeReg && typeflag != tar.TypeRegA {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if hdr.Size > 0 {
if _, err := tw.Write(entry.body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if _, err := gz.Write(trailing); err != nil {
t.Fatalf("write trailing gzip payload: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// buildTestArchivePAX writes an archive whose first entry uses PAX extended
// headers (forced by a custom PAX record). archive/tar consumes those records
// before the entry header surfaces, so validation must reject the format even
// though the visible name is allowlisted.
func buildTestArchivePAX(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
body := []byte(`{"schema_version":1}`)
hdr := &tar.Header{
Name: "manifest.json",
Mode: 0o600,
Size: int64(len(body)),
Typeflag: tar.TypeReg,
Format: tar.FormatPAX,
PAXRecords: map[string]string{"SILO.smuggled": "payload"},
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write pax header: %v", err)
}
if _, err := tw.Write(body); err != nil {
t.Fatalf("write pax body: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func buildTestGzipMember(t *testing.T, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(payload); err != nil {
t.Fatalf("write gzip payload: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func fmtHex(raw []byte) string {
const table = "0123456789abcdef"
out := make([]byte, len(raw)*2)
for i, b := range raw {
out[i*2] = table[b>>4]
out[i*2+1] = table[b&0x0f]
}
return string(out)
}