Implements slice 1 of docs/design/2026-07-19-client-diagnostics.md: the versioned contract (schemas, fixtures, Go validator), storage-validated diagnostics.uploads_enabled gate, account-scoped status endpoint, hardened streaming multipart ingest with quota reservation and a receiving/ready/ failed report state machine, S3 streaming puts, acting-admin report API (list/detail/download/delete with audit events), and the retention + orphan-reconciliation cleanup task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XppCCycoaskCsW7ja1fZct
261 lines
6.2 KiB
Go
261 lines
6.2 KiB
Go
package diagnostics
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"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
|
|
}{
|
|
{
|
|
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(`{}`)},
|
|
{name: "logs.jsonl", body: []byte(strings.Repeat("a", 1024*1024))},
|
|
},
|
|
limits: BundleLimits{
|
|
MaxUncompressedBytes: 2 * 1024 * 1024,
|
|
},
|
|
wantErr: ErrCompressionRatio,
|
|
},
|
|
{
|
|
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: "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,
|
|
},
|
|
}
|
|
|
|
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[:]))
|
|
}
|
|
if got, want := strings.Join(info.Entries, ","), "manifest.json,logs.jsonl"; got != want {
|
|
t.Fatalf("entries = %s, want %s", got, want)
|
|
}
|
|
if info.UncompressedBytes != int64(len(validEntries[0].body)+len(validEntries[1].body)) {
|
|
t.Fatalf("uncompressed bytes = %d", info.UncompressedBytes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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)
|
|
}
|