Files
flock/pkg/agent/annotations_fuzz_test.go
Donavan Fritz c860e9351b
Build flock Image / build (push) Has been cancelled
ip-algo: pod annotation > NodeConfig annotation > random
Add flock.fritzlab.net/ip-algo as a node-wide default via NodeConfig
metadata.annotations. Pod-level annotation still wins. Empty, missing,
or invalid input at either level falls through to the next; invalid
values warn-log via the agent's slog. Both unset → fully random IID
(unchanged baseline).

ParseAnnotations no longer touches ip-algo; ResolveIPAlgo handles the
full precedence chain, called from PodHandler.Add with the cached
NodeConfig's annotations and the agent logger.

Tests: 9 new TestResolveIPAlgo_* cases covering pod-wins, all
fall-through paths, both-absent, nil node map, whitespace, and
duplicate-as-invalid. Fuzz target rebuilt without ip-algo input space
(now exercised by ResolveIPAlgo unit tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:09:09 -05:00

146 lines
5.2 KiB
Go

package agent
import (
"testing"
)
// FuzzParseAnnotations explores the joint space of {ipv6, ipv4, cidr6, cidr4,
// anycast} annotations with random byte strings. ip-algo is handled by
// ResolveIPAlgo (separate fuzz target below) and is no longer touched by
// ParseAnnotations. Every recognised key is exercised by deriving a
// deterministic input map from the fuzzed bytes.
//
// Properties checked:
//
// 1. The parser never panics on any input.
// 2. On nil-error return, the result satisfies the design-doc invariant
// that at least one of WantV6 / WantV4 is true (a pod always has at
// least one address).
// 3. Anycast IPs and CIDR slices are non-nil/empty only when the
// annotation was supplied; never spontaneously populated.
//
// Seed corpus covers known edge cases the spec must handle.
func FuzzParseAnnotations(f *testing.F) {
// Seeds: each entry is five strings — the literal raw values for the
// five parsed keys. Empty string for "key absent".
type seed struct {
ipv6, ipv4, cidr6, cidr4, anycast string
}
seeds := []seed{
{},
{ipv4: "true"},
{ipv6: "false", ipv4: "true"},
{ipv6: "TRUE"},
{ipv6: " true "},
{ipv6: "yes"}, // invalid → expect error
{ipv4: "1"}, // invalid
{cidr6: ""}, // invalid (empty after split)
{cidr6: ","}, // invalid (empty after trim)
{cidr6: "2602:817:3000:f001::/64"}, // valid single
{cidr6: "2602:817:3000:f001::/64,"}, // trailing comma
{cidr6: " 2602:817:3000:f001::/64 "}, // surrounding whitespace
{cidr6: "2602:817:3000:f001::/64, 2602:817:3000:f002::/64"},
{cidr6: "10.0.0.0/8"}, // family mismatch
{cidr4: "172.25.210.0/24"}, // valid
{cidr4: "172.25.210.0/24,172.25.211.0/24"}, // multiple
{cidr4: "2602:817::/32"}, // family mismatch
{anycast: "2602:817:3000:ac::1"},
{anycast: "2602:817:3000:ac::1, 172.25.255.1"},
{anycast: "::1"}, // loopback (allowed at parse time)
{anycast: "fe80::1"}, // link-local (allowed at parse time)
{anycast: "::ffff:10.0.0.1"}, // v4-mapped v6
{anycast: "0.0.0.0"}, // unspecified
{anycast: "definitely-not-an-ip"}, // invalid
{anycast: ""}, // invalid
// Embedded NUL bytes
{ipv4: "true\x00"},
{cidr6: "2602:817:3000:f001::/64\x00"},
{anycast: "\x00\x00"},
// Unicode
{ipv4: "trüe"},
// Very long
{cidr6: longString("2602:817:3000:f001::/64,", 4096)},
}
for _, s := range seeds {
f.Add(s.ipv6, s.ipv4, s.cidr6, s.cidr4, s.anycast)
}
f.Fuzz(func(t *testing.T, ipv6, ipv4, cidr6, cidr4, anycast string) {
in := map[string]string{}
// Treat empty as "key absent" so the seed table matches the run-time
// shape; Kubernetes annotations cannot have a nil value but they CAN
// be missing entirely. Empty-string-with-key is also a real case
// (operator typo); add a separate seed below to cover it.
if ipv6 != "" {
in[annotationPrefix+annIPv6] = ipv6
}
if ipv4 != "" {
in[annotationPrefix+annIPv4] = ipv4
}
if cidr6 != "" {
in[annotationPrefix+annCIDR6] = cidr6
}
if cidr4 != "" {
in[annotationPrefix+annCIDR4] = cidr4
}
if anycast != "" {
in[annotationPrefix+annAnycast] = anycast
}
got, err := ParseAnnotations(in, BuiltinFamilyDefaults())
if err != nil {
return // any error is acceptable; we only require no panic
}
// Property: at least one family must be selected.
if !got.WantV6 && !got.WantV4 {
t.Fatalf("parser accepted but produced no family: in=%#v", in)
}
// Property: optional fields populated only when their key was set.
if _, hasAny := in[annotationPrefix+annAnycast]; !hasAny && len(got.Anycast) != 0 {
t.Fatalf("Anycast populated without annotation")
}
if _, hasC6 := in[annotationPrefix+annCIDR6]; !hasC6 && len(got.CIDR6) != 0 {
t.Fatalf("CIDR6 populated without annotation")
}
if _, hasC4 := in[annotationPrefix+annCIDR4]; !hasC4 && len(got.CIDR4) != 0 {
t.Fatalf("CIDR4 populated without annotation")
}
})
}
// FuzzParseCNIArgs requires the parser to never panic on adversarial inputs.
// The parser is permissive by spec — it returns a CNIArgs with whatever it
// could extract — so the only invariant is "doesn't crash".
func FuzzParseCNIArgs(f *testing.F) {
f.Add("")
f.Add("=")
f.Add(";")
f.Add(";=;=;")
f.Add("K8S_POD_NAMESPACE=ns;K8S_POD_NAME=p")
f.Add("K8S_POD_NAMESPACE=ns;K8S_POD_NAME=p;K8S_POD_UID=abc;K8S_POD_INFRA_CONTAINER_ID=def")
f.Add("=value-only")
f.Add("key-only=")
f.Add("\x00\x00\x00")
f.Add("K8S_POD_NAMESPACE=\xff\xfe\xfd")
f.Add("K8S_POD_NAME=value;K8S_POD_NAME=other") // duplicate keys: last wins
// Long input
f.Add(longString("K8S_POD_NAME=x;", 4096))
f.Fuzz(func(t *testing.T, in string) {
_ = ParseCNIArgs(in)
})
}
// longString returns s repeated to total >= n bytes, useful for piling up
// realistic-looking but oversized inputs.
func longString(s string, n int) string {
if len(s) == 0 {
return ""
}
var b []byte
for len(b) < n {
b = append(b, s...)
}
return string(b)
}