Skip to content

Commit b8c6f8e

Browse files
committed
descriptors: add fuzz tests
1 parent a6d7247 commit b8c6f8e

2 files changed

Lines changed: 291 additions & 0 deletions

File tree

descriptors/fuzz_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package descriptors
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/btcsuite/btcd/chaincfg/v2"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// maxFuzzInput bounds the size of a fuzz input. Descriptor parsing recurses
14+
// into miniscript, whose threshold passes are exponential in the number of
15+
// thresh sub expressions, so this keeps individual executions fast while
16+
// leaving room to reach every descriptor and script shape.
17+
const maxFuzzInput = 4096
18+
19+
// gPointX is the x coordinate of the secp256k1 generator, used to build seed
20+
// descriptors with valid raw keys (which, unlike the xpub seeds, survive
21+
// mutation without breaking base58 decoding).
22+
const gPointX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f28" +
23+
"15b16f81798"
24+
25+
// hardcodedDescriptorSeeds keep the seed set representative even if the corpus
26+
// file is unavailable, and add raw-key forms that mutate more gracefully than
27+
// the long xpub corpus entries.
28+
var hardcodedDescriptorSeeds = []string{
29+
"pk(" + basicTestXpub + ")",
30+
"pkh(" + basicTestXpub + "/*)",
31+
"wpkh(" + basicTestXpub + "/<0;1>/*)",
32+
"sh(wpkh(" + basicTestXpub + "/*))",
33+
"wsh(pk(" + basicTestXpub + "/*))",
34+
"sh(wsh(pkh(" + basicTestXpub + "/*)))",
35+
"wsh(multi(1,02" + gPointX + "))",
36+
"tr(" + gPointX + ")",
37+
"tr(" + gPointX + ",pk(" + gPointX + "))",
38+
"tr(" + gPointX + ",{pk(" + gPointX + "),pk(" + gPointX + ")})",
39+
}
40+
41+
// FuzzNewDescriptor fuzzes descriptor parsing and, on every successful parse,
42+
// exercises the deep downstream methods (type classification, lifting, address
43+
// and script-code derivation, weight estimation and planning). It checks the
44+
// no-crash property throughout, plus the invariant that the descriptor's string
45+
// form is a stable, re-parseable fixed point.
46+
func FuzzNewDescriptor(f *testing.F) {
47+
addDescriptorSeeds(f, filepath.Join(
48+
"testdata", "descriptors_corpus.txt",
49+
))
50+
for _, seed := range hardcodedDescriptorSeeds {
51+
f.Add(seed)
52+
}
53+
54+
f.Fuzz(func(t *testing.T, desc string) {
55+
if len(desc) > maxFuzzInput {
56+
return
57+
}
58+
59+
d, err := NewDescriptor(desc)
60+
if err != nil {
61+
return
62+
}
63+
exerciseDescriptor(t, d)
64+
})
65+
}
66+
67+
// exerciseDescriptor drives the deep operations of a successfully parsed
68+
// descriptor, asserting only invariants that must hold for any valid one.
69+
func exerciseDescriptor(t *testing.T, d *Descriptor) {
70+
// The string form must be a stable fixed point: re-parsing it must
71+
// succeed and reproduce exactly the same string (the checksum is
72+
// recomputed on each round).
73+
s := d.String()
74+
reparsed, err := NewDescriptor(s)
75+
require.NoErrorf(t, err, "re-parsing own string %q failed", s)
76+
require.Equal(t, s, reparsed.String(),
77+
"descriptor string is not a stable fixed point")
78+
79+
// The remaining methods must never panic on a valid descriptor.
80+
_ = d.DescType()
81+
_ = d.Keys()
82+
_, _ = d.Lift()
83+
_, _ = d.MaxWeightToSatisfy()
84+
85+
multipath := d.MultipathLen()
86+
require.GreaterOrEqualf(t, multipath, 1,
87+
"multipath length must be at least 1 for %q", s)
88+
89+
// Derive an address and script code for a bounded sample of the
90+
// multipath sub-descriptors; both may legitimately error, but must not
91+
// panic.
92+
params := &chaincfg.RegressionNetParams
93+
for mp := uint32(0); int(mp) < multipath && mp < 4; mp++ {
94+
_, _ = d.AddressAt(params, mp, 0)
95+
_, _ = d.ScriptCodeAt(mp, 0)
96+
}
97+
98+
// Planning with everything available drives the plan builder and its
99+
// satisfaction machinery.
100+
_, _ = d.PlanAt(0, 0, Assets{
101+
LookupEcdsaSig: func(string) bool {
102+
return true
103+
},
104+
LookupTapKeySpendSig: func(string) (uint32, bool) {
105+
return 64, true
106+
},
107+
LookupTapLeafScriptSig: func(string, string) (uint32, bool) {
108+
return 64, true
109+
},
110+
})
111+
}
112+
113+
// addDescriptorSeeds adds each non-empty, non-comment line of the given file as
114+
// a fuzz seed. A missing file is ignored so the fuzzer stays usable without the
115+
// full corpus.
116+
func addDescriptorSeeds(f *testing.F, path string) {
117+
data, err := os.ReadFile(path)
118+
if err != nil {
119+
return
120+
}
121+
122+
for _, line := range strings.Split(string(data), "\n") {
123+
line = strings.TrimSpace(line)
124+
if line == "" || strings.HasPrefix(line, "#") {
125+
continue
126+
}
127+
f.Add(line)
128+
}
129+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package miniscript
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// maxFuzzInput bounds the size of a fuzz input. Parse's threshold passes are
14+
// exponential in the number of thresh sub expressions, so an unbounded input
15+
// could construct a pathologically expensive (though still bounded) parse; this
16+
// keeps individual executions fast while leaving plenty of room to reach every
17+
// fragment. Active fuzzing may still surface the known exponential-thresh cost
18+
// for a compact but large threshold.
19+
const maxFuzzInput = 4096
20+
21+
// miniscriptFuzzSeedFiles are the committed corpora fed in as fuzz seeds. Their
22+
// first whitespace-separated token is the expression (the remaining columns are
23+
// the expected type and op count in some files).
24+
var miniscriptFuzzSeedFiles = []string{
25+
"valid_from_alloy.txt",
26+
"valid_8f1e8_from_alloy.txt",
27+
"malleable_from_alloy.txt",
28+
"conflict_from_alloy.txt",
29+
"invalid_from_alloy.txt",
30+
"edge_cases.txt",
31+
"opcodes.txt",
32+
"invalid.txt",
33+
}
34+
35+
// hardcodedMiniscriptSeeds covers every fragment kind with single-letter keys,
36+
// keeping the seed set representative even without the corpus files.
37+
var hardcodedMiniscriptSeeds = []string{
38+
"0",
39+
"1",
40+
"pk(A)",
41+
"pkh(A)",
42+
"pk_k(A)",
43+
"pk_h(A)",
44+
"older(144)",
45+
"after(500000000)",
46+
"sha256(" + strings.Repeat("0", 64) + ")",
47+
"and_v(v:pk(A),pk(B))",
48+
"and_b(pk(A),s:pk(B))",
49+
"or_b(pk(A),s:pk(B))",
50+
"or_c(pk(A),v:pk(B))",
51+
"or_d(pk(A),pk(B))",
52+
"or_i(pk(A),pk(B))",
53+
"andor(pk(A),pk(B),pk(C))",
54+
"thresh(2,pk(A),s:pk(B),s:pk(C))",
55+
"multi(2,A,B,C)",
56+
"multi_a(2,A,B,C)",
57+
"and_v(v:pk(A),or_d(pk(B),older(65535)))",
58+
}
59+
60+
// FuzzParse fuzzes miniscript parsing and, on every successful parse, exercises
61+
// the deep downstream passes (lifting, satisfaction sizing, key extraction,
62+
// script generation and satisfaction) in both script contexts. It checks the
63+
// no-crash property throughout, plus the invariant that the statically computed
64+
// script length matches the length of the actually generated script.
65+
func FuzzParse(f *testing.F) {
66+
for _, dir := range []string{"testdata"} {
67+
for _, name := range miniscriptFuzzSeedFiles {
68+
addMiniscriptSeeds(f, filepath.Join(dir, name))
69+
}
70+
}
71+
for _, seed := range hardcodedMiniscriptSeeds {
72+
f.Add(seed)
73+
}
74+
75+
f.Fuzz(func(t *testing.T, expr string) {
76+
if len(expr) > maxFuzzInput {
77+
return
78+
}
79+
80+
// A miniscript is valid (or not) largely independent of the
81+
// context, but the allowed fragments differ (multi vs multi_a),
82+
// so both are worth exercising.
83+
for _, ctx := range []Context{P2WSH, P2TR} {
84+
node, err := Parse(expr, ctx)
85+
if err != nil {
86+
continue
87+
}
88+
exerciseAST(t, node, ctx, expr)
89+
}
90+
})
91+
}
92+
93+
// exerciseAST drives the deep, key-independent and key-dependent operations of
94+
// a successfully parsed miniscript, asserting only invariants that hold for any
95+
// valid expression.
96+
func exerciseAST(t *testing.T, node *AST, ctx Context, expr string) {
97+
// Key-independent operations must never panic.
98+
_ = node.DrawTree()
99+
_ = node.Keys()
100+
_, _ = node.Lift()
101+
_, _ = node.MaxSatisfactionSize()
102+
_, _ = node.MaxSatisfactionWitnessElements()
103+
104+
// Substitute a concrete key of the length the context expects, then
105+
// check that the compiled script has exactly the statically computed
106+
// length. This ties the size pass to the real script builder.
107+
keyLen := 33
108+
if ctx == P2TR {
109+
keyLen = 32
110+
}
111+
dummyKey := append(
112+
[]byte{0x02}, bytes.Repeat([]byte{0x01}, keyLen-1)...,
113+
)
114+
115+
err := node.ApplyVars(func(string) ([]byte, error) {
116+
return dummyKey, nil
117+
})
118+
if err != nil {
119+
return
120+
}
121+
122+
script, err := node.Script()
123+
if err == nil {
124+
require.Equalf(t, node.ScriptLen(), len(script),
125+
"ScriptLen mismatch for %q in %v", expr, ctx)
126+
}
127+
128+
// A satisfier that provides everything drives the full satisfaction
129+
// machinery; it must not panic regardless of the shape of the script.
130+
_, _ = node.Satisfy(&Satisfier{
131+
Sign: func([]byte) ([]byte, bool) {
132+
return make([]byte, 72), true
133+
},
134+
Preimage: func(string, []byte) ([]byte, bool) {
135+
return make([]byte, 32), true
136+
},
137+
CheckOlder: func(uint32) (bool, error) {
138+
return true, nil
139+
},
140+
CheckAfter: func(uint32) (bool, error) {
141+
return true, nil
142+
},
143+
})
144+
}
145+
146+
// addMiniscriptSeeds adds the first token of each non-empty, non-comment line
147+
// of the given file as a fuzz seed. A missing file is ignored so the fuzzer
148+
// stays usable without the full corpus.
149+
func addMiniscriptSeeds(f *testing.F, path string) {
150+
data, err := os.ReadFile(path)
151+
if err != nil {
152+
return
153+
}
154+
155+
for _, line := range strings.Split(string(data), "\n") {
156+
line = strings.TrimSpace(line)
157+
if line == "" || strings.HasPrefix(line, "#") {
158+
continue
159+
}
160+
f.Add(strings.Fields(line)[0])
161+
}
162+
}

0 commit comments

Comments
 (0)