Files
silo-server/internal/diagnostics/bundle_test.go
T
Quick104andClaude Fable 5 f965a489bd fix(diagnostics): align bundle contract with real tar writers
Two validator behaviors made the contract unimplementable for clients
using standard tar libraries:

- Any byte after the tar end-of-archive marker was rejected, but GNU
  tar, Python tarfile, and Apache Commons Compress all pad the archive
  with zero blocks to a record boundary. Accept up to 64 KiB of zero
  padding; any non-zero trailing data is still rejected.

- uncompressed_bytes was computed as the sum of entry payloads, which
  no tar-producing client observes. Define it as the total decompressed
  tar stream (headers, end-of-archive marker, and padding included) —
  the byte count between a client's tar writer and gzip writer, and
  what gzip -l reports. Documented in the design doc and contract.

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

295 lines
7.2 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
}{
{
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: "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,
},
}
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)
}
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()
}
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)
}