- 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
403 lines
10 KiB
Go
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)
|
|
}
|