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
295 lines
7.2 KiB
Go
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)
|
|
}
|