package bundle import ( "archive/tar" "bytes" "compress/gzip" "crypto/ed25519" "errors" "fmt" "io" "os" "path/filepath" "runtime" "strings" "testing" ) type testEntry struct { header tar.Header body []byte } func archiveEntries(t *testing.T, entries []testEntry) []byte { t.Helper() var b bytes.Buffer gz := gzip.NewWriter(&b) tw := tar.NewWriter(gz) for _, entry := range entries { h := entry.header if h.Typeflag == 0 { h.Typeflag = tar.TypeReg } h.Size = int64(len(entry.body)) if err := tw.WriteHeader(&h); err != nil { t.Fatal(err) } if _, err := tw.Write(entry.body); err != nil { t.Fatal(err) } } if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } return b.Bytes() } func testKey() ed25519.PrivateKey { return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)) } func TestBundleResourceLimits(t *testing.T) { pub := testKey().Public().(ed25519.PublicKey) cases := []struct { name string entries []testEntry limits Limits }{ {"file", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxFileBytes: 1024}}, {"expanded", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 8192)}}, Limits{MaxExpandedBytes: 4096}}, {"manifest", []testEntry{{header: tar.Header{Name: manifestName}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxManifestBytes: 1024}}, {"entries", []testEntry{{header: tar.Header{Name: "a"}}, {header: tar.Header{Name: "b"}}, {header: tar.Header{Name: "c"}}}, Limits{MaxEntries: 2}}, {"pax-metadata", []testEntry{{header: tar.Header{Name: "bird.conf", Format: tar.FormatPAX, PAXRecords: map[string]string{"comment": strings.Repeat("a", 8192)}}}}, Limits{MaxExpandedBytes: 4096}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { raw := archiveEntries(t, tc.entries) if _, err := VerifyGzippedTarWithLimits(raw, pub, tc.limits); !errors.Is(err, ErrLimit) { t.Fatalf("want limit, got %v", err) } }) } if _, err := VerifyGzippedTarWithLimits(make([]byte, 65), pub, Limits{MaxCompressedBytes: 64}); !errors.Is(err, ErrLimit) { t.Fatalf("compressed cap: %v", err) } } func TestBundleRejectsUnsafeEntries(t *testing.T) { pub := testKey().Public().(ed25519.PublicKey) for _, name := range []string{"../escape", "/etc/bird.conf", `bird.d\escape`, "C:/escape", "bird.d/../escape", "a//b", "a/./b", "NUL", "bird.conf:stream", "a. /b"} { t.Run(name, func(t *testing.T) { raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: name}, body: []byte("x")}}) if _, err := VerifyGzippedTar(raw, pub); err == nil { t.Fatal("unsafe path accepted") } }) } for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeDir, tar.TypeFifo} { raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf", Typeflag: kind, Linkname: "outside"}}}) if _, err := VerifyGzippedTar(raw, pub); err == nil { t.Fatalf("type %d accepted", kind) } } raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf"}}, {header: tar.Header{Name: "bird.conf"}}}) if _, err := VerifyGzippedTar(raw, pub); err == nil || !strings.Contains(err.Error(), "duplicate") { t.Fatalf("duplicate accepted: %v", err) } } func TestBundleChecksTrailerAndTrailingExpansion(t *testing.T) { priv := testKey() pub := priv.Public().(ed25519.PublicKey) raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "router id 192.0.2.1;"}, priv) if err != nil { t.Fatal(err) } corrupt := append([]byte{}, raw...) corrupt[len(corrupt)-8] ^= 0xff if _, err = VerifyGzippedTar(corrupt, pub); err == nil { t.Fatal("gzip checksum ignored after tar EOF") } var extra bytes.Buffer gz := gzip.NewWriter(&extra) if _, err = gz.Write(bytes.Repeat([]byte{'x'}, 32<<10)); err != nil { t.Fatal(err) } if err = gz.Close(); err != nil { t.Fatal(err) } joined := append(append([]byte{}, raw...), extra.Bytes()...) if _, err = VerifyGzippedTarWithLimits(joined, pub, Limits{MaxExpandedBytes: 8 << 10}); !errors.Is(err, ErrLimit) { t.Fatalf("trailing gzip member bypassed budget: %v", err) } } func TestBundleRejectsPAXSparseRegularFile(t *testing.T) { // archive/tar expands PAX sparse holes even though the returned type is a // regular file. Build a physical one-byte entry with a 4096-byte logical size. record := func(key, value string) string { body := key + "=" + value + "\n" n := len(body) + 2 for { next := len(fmt.Sprint(n)) + 1 + len(body) if next == n { return fmt.Sprintf("%d %s", n, body) } n = next } } pax := record("GNU.sparse.size", "4096") + record("GNU.sparse.numblocks", "1") + record("GNU.sparse.map", "0,1") var physical bytes.Buffer tw := tar.NewWriter(&physical) for _, entry := range []testEntry{{header: tar.Header{Name: "PaxHeader", Size: int64(len(pax))}, body: []byte(pax)}, {header: tar.Header{Name: "bird.conf", Size: 1}, body: []byte("x")}} { if err := tw.WriteHeader(&entry.header); err != nil { t.Fatal(err) } if _, err := tw.Write(entry.body); err != nil { t.Fatal(err) } } if err := tw.Close(); err != nil { t.Fatal(err) } data := physical.Bytes() data[156] = tar.TypeXHeader for i := 148; i < 156; i++ { data[i] = ' ' } sum := 0 for _, b := range data[:512] { sum += int(b) } copy(data[148:156], fmt.Sprintf("%06o\x00 ", sum)) h, err := tar.NewReader(bytes.NewReader(data)).Next() if err != nil || h.Typeflag != tar.TypeReg || h.Size != 4096 { t.Fatalf("invalid sparse regression fixture %+v %v", h, err) } var compressed bytes.Buffer gz := gzip.NewWriter(&compressed) if _, err = gz.Write(data); err != nil { t.Fatal(err) } if err = gz.Close(); err != nil { t.Fatal(err) } if _, err = VerifyGzippedTar(compressed.Bytes(), testKey().Public().(ed25519.PublicKey)); err == nil || !strings.Contains(err.Error(), "sparse") { t.Fatalf("PAX sparse accepted: %v", err) } } func TestBundleRejectsUnmanifestedPayload(t *testing.T) { priv := testKey() raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "x"}, priv) if err != nil { t.Fatal(err) } gr, err := gzip.NewReader(bytes.NewReader(raw)) if err != nil { t.Fatal(err) } defer func() { _ = gr.Close() }() tr := tar.NewReader(gr) var entries []testEntry for { h, e := tr.Next() if e == io.EOF { break } if e != nil { t.Fatal(e) } b, e := io.ReadAll(tr) if e != nil { t.Fatal(e) } entries = append(entries, testEntry{header: *h, body: b}) } entries = append(entries, testEntry{header: tar.Header{Name: "unsigned.conf"}, body: []byte("unsigned")}) if _, err = VerifyGzippedTar(archiveEntries(t, entries), priv.Public().(ed25519.PublicKey)); err == nil { t.Fatal("unmanifested payload accepted") } } func TestExtractionDoesNotFollowLinks(t *testing.T) { root := t.TempDir() outside := filepath.Join(t.TempDir(), "outside") if err := os.WriteFile(outside, []byte("preserve"), 0o600); err != nil { t.Fatal(err) } dest := filepath.Join(root, "bird.conf") if err := os.Link(outside, dest); err != nil { if runtime.GOOS == "linux" { t.Fatalf("Linux link regression requires hard links: %v", err) } t.Skipf("hard links unavailable: %v", err) } v := &VerifiedContents{Manifest: Manifest{Files: []FileEntry{{Path: "bird.conf"}}}, Files: map[string][]byte{"bird.conf": []byte("replacement")}} if err := WriteExtractedFiles(root, v); err != nil { t.Fatal(err) } got, err := os.ReadFile(outside) if err != nil || string(got) != "preserve" { t.Fatalf("hard link modified outside: %s %v", got, err) } if err = os.Remove(dest); err != nil { t.Fatal(err) } if err = os.Symlink(outside, dest); err != nil { if runtime.GOOS == "linux" { t.Fatalf("Linux link regression requires symlinks: %v", err) } t.Skipf("symlinks unavailable: %v", err) } if err = WriteExtractedFiles(root, v); err == nil { t.Fatal("symlink destination accepted") } got, err = os.ReadFile(outside) if err != nil || string(got) != "preserve" { t.Fatal("symlink modified outside") } } func TestBundleBuilderBoundsAndPortablePaths(t *testing.T) { for _, name := range []string{"/bird.conf", "a/../bird.conf", manifestName, sigFileName, `bird.d\bad`} { if _, err := BuildGzippedTar("r", "s", map[string]string{name: "x"}, testKey()); err == nil { t.Fatalf("invalid builder path accepted: %q", name) } } if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": strings.Repeat("x", 1025)}, testKey(), Limits{MaxFileBytes: 1024}); !errors.Is(err, ErrLimit) { t.Fatalf("builder file cap %v", err) } if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": "x"}, testKey(), Limits{MaxExpandedBytes: 1024}); !errors.Is(err, ErrLimit) { t.Fatalf("builder tar overhead cap %v", err) } } // This is a decoded-size regression using repeated representative route lines, // not a unique-prefix cardinality test or a BIRD validity/performance benchmark. func TestDefaultLimitsAllowMillionRepresentativeIPv6RouteLines(t *testing.T) { if testing.Short() { t.Skip("large representative config") } config := strings.Repeat("route 2001:db8:1234:5678::/64 blackhole;\n", 1_000_000) raw, err := BuildGzippedTar("large", "speaker", map[string]string{"bird.conf": config}, testKey()) if err != nil { t.Fatal(err) } v, err := VerifyGzippedTar(raw, testKey().Public().(ed25519.PublicKey)) if err != nil { t.Fatal(err) } if len(v.Files["bird.conf"]) != len(config) { t.Fatal("large config truncated") } }