Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
287 lines
9.4 KiB
Go
287 lines
9.4 KiB
Go
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")
|
|
}
|
|
}
|