Files
DDI/internal/bundle/bundle_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 e843502cbf feat: M3 contracts for agents, revisions and bundles
Shared Apache-2.0 packages for the signed bundle format and the agent
certificate conventions, the agent role interface, REST API spec for
agents, enrollment tokens, revisions, deployments and leases, and ADR
0021. Generator settings now hold the target paths on the managed hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:30 +02:00

173 lines
6.4 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package bundle
import (
"bytes"
"crypto/ed25519"
"strings"
"testing"
"time"
)
var testTime = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
func testKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
seed := bytes.Repeat([]byte{7}, ed25519.SeedSize)
priv := ed25519.NewKeyFromSeed(seed)
return priv.Public().(ed25519.PublicKey), priv
}
func sample() []Artifact {
return []Artifact{
{Path: "ddi-suite.conf", Mode: 0o640, Content: []byte("zone \"a.test\" {};\n")},
{Path: "zones/db.a.test", Mode: 0o640, Content: []byte("$TTL 3600\n")},
{Path: "zones/db.dyn.test", Mode: 0o640, Content: []byte("dyn\n"), CreateOnly: true},
}
}
func contents(arts []Artifact) map[string][]byte {
m := make(map[string][]byte, len(arts))
for _, a := range arts {
m[a.Path] = a.Content
}
return m
}
func TestSignVerifyRoundTrip(t *testing.T) {
pub, priv := testKey(t)
arts := sample()
man, sig, err := Sign(priv, "agent-1", "dns", 42, testTime, arts)
if err != nil {
t.Fatal(err)
}
m, err := Verify(pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 42})
if err != nil {
t.Fatal(err)
}
if m.Revision != 42 || len(m.Files) != 3 || !m.Files[2].CreateOnly || m.Files[0].Mode != 0o640 ||
!m.CreatedAt.Equal(testTime) || m.Files[1].Size != int64(len(arts[1].Content)) {
t.Errorf("manifest %+v", m)
}
// Signing is deterministic: same input, same bytes.
man2, sig2, _ := Sign(priv, "agent-1", "dns", 42, testTime, arts)
if !bytes.Equal(man, man2) || !bytes.Equal(sig, sig2) {
t.Error("signing is not deterministic")
}
}
func TestVerifyRejects(t *testing.T) {
pub, priv := testKey(t)
arts := sample()
man, sig, err := Sign(priv, "agent-1", "dns", 5, testTime, arts)
if err != nil {
t.Fatal(err)
}
ok := Expect{AgentID: "agent-1", Role: "dns"}
otherPub, _, _ := ed25519.GenerateKey(nil)
tampered := bytes.Replace(man, []byte(`"revision":5`), []byte(`"revision":6`), 1)
changed := contents(arts)
changed["zones/db.a.test"] = []byte("$TTL 3601\n")
missing := contents(arts)
delete(missing, "zones/db.a.test")
extra := contents(arts)
extra["other"] = nil
short := contents(arts)
short["zones/db.a.test"] = []byte("x")
cases := []struct {
name string
pub ed25519.PublicKey
man, sig []byte
files map[string][]byte
want Expect
msg string
}{
{"wrong key", otherPub, man, sig, contents(arts), ok, "signature"},
{"bad key length", pub[:5], man, sig, contents(arts), ok, "signing key"},
{"tampered manifest", pub, tampered, sig, contents(arts), ok, "signature"},
{"other agent", pub, man, sig, contents(arts), Expect{AgentID: "agent-2", Role: "dns"}, "agent"},
{"other role", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dhcp"}, "role"},
{"replay", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 6}, "older"},
{"changed content", pub, man, sig, changed, ok, "SHA-256"},
{"wrong size", pub, man, sig, short, ok, "size"},
{"missing file", pub, man, sig, missing, ok, "files"},
{"extra file", pub, man, sig, extra, ok, "files"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := Verify(c.pub, c.man, c.sig, c.files, c.want)
if err == nil || !strings.Contains(err.Error(), c.msg) {
t.Errorf("err = %v, want %q", err, c.msg)
}
})
}
}
func TestVerifyRejectsUnsafeManifests(t *testing.T) {
pub, priv := testKey(t)
sign := func(raw string) ([]byte, []byte) { return []byte(raw), ed25519.Sign(priv, []byte(raw)) }
cases := map[string]string{
"format": `{"format":2,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]}`,
"no rev": `{"format":1,"agent_id":"a","role":"dns","revision":0,"created_at":"2026-10-13T12:00:00Z","files":[]}`,
"unknown": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[],"x":1}`,
"trailing": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]} {}`,
"escape": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"../etc/passwd","mode":420,"sha256":"","size":0}]}`,
"mode": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":2048,"sha256":"","size":0}]}`,
"dup": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":0},{"path":"x","mode":420,"sha256":"","size":0}]}`,
"negative": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":-1}]}`,
"huge": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":1073741824}]}`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
man, sig := sign(raw)
if _, err := Verify(pub, man, sig, map[string][]byte{}, Expect{AgentID: "a", Role: "dns"}); err == nil {
t.Error("accepted")
}
})
}
if _, _, err := Sign(priv[:3], "a", "dns", 1, testTime, nil); err == nil {
t.Error("Sign accepted a short key")
}
if _, _, err := Sign(priv, "a", "dns", 1, testTime, []Artifact{{Path: "/abs"}}); err == nil {
t.Error("Sign accepted an absolute path")
}
}
func TestCheckPath(t *testing.T) {
good := []string{"ddi-suite.conf", "zones/db.office.test", "kea-dhcp4.conf", "a/b/c+d@e_f-g"}
bad := []string{"", "/abs", "../x", "a/../b", "a//b", "a/", ".hidden", "zones/.x", "a b", "a\\b", "ä",
strings.Repeat("a", MaxPathLen+1)}
for _, p := range good {
if err := CheckPath(p); err != nil {
t.Errorf("%q: %v", p, err)
}
}
for _, p := range bad {
if CheckPath(p) == nil {
t.Errorf("%q accepted", p)
}
}
}
func FuzzVerify(f *testing.F) {
pub, priv := testKey(&testing.T{})
man, _, _ := Sign(priv, "a", "dns", 1, testTime, sample())
f.Add(man)
f.Fuzz(func(t *testing.T, raw []byte) {
// Whatever is signed, Verify must not panic and must never accept a
// path outside the root.
m, err := Verify(pub, raw, ed25519.Sign(priv, raw), map[string][]byte{}, Expect{AgentID: "a", Role: "dns"})
if err != nil {
return
}
for _, file := range m.Files {
if CheckPath(file.Path) != nil {
t.Fatalf("accepted unsafe path %q", file.Path)
}
}
})
}