From 77df90d6e74fc5e03bc9e6ff08e3d7ee65262b1f Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Sun, 11 Oct 2026 10:49:58 +0200 Subject: [PATCH 1/6] Compare btclib with btcd's wire decoder over the fuzz corpora (issue #2584) fuzz/btcd_decode is a Go program, pinned to btcd's wire/v2 v2.0.1 in its own go.mod and go.sum, that decodes a file with btcd and prints whether it was accepted and the octets btcd writes back. fuzz/btcd_oracle.py runs it and btclib over fuzz/corpus and over downloaded cifuzz-corpus-* artifacts, and lists every input the two read differently: one accepts and the other refuses, both accept and write different octets, or one crashes. btclib's validity checks are off for the comparison, because btcd's decoders leave them to the caller. Differences already triaged against Bitcoin Core (a8b69bdeaf) are rules in the script, keyed by what the two answered. They are counted and not listed, so the run is red only for a disagreement no rule explains. A version message without its relay flag is compared as Core reads it, with the flag true. btcd-oracle.yml runs the comparison weekly after the fuzz run, over the seeds and that run's artifacts, and on a pull request touching the Go module or the script. It is informational. Its cron needs the calendar row of btclib-org/.github#1688. dependabot.yml gains a gomod entry for the Go module. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ferdinando Ametrano --- .github/dependabot.yml | 17 +- .github/workflows/btcd-oracle.yml | 119 ++++++++ CONTRIBUTING.md | 1 + fuzz/btcd_decode/go.mod | 13 + fuzz/btcd_decode/go.sum | 16 + fuzz/btcd_decode/main.go | 260 ++++++++++++++++ fuzz/btcd_oracle.py | 487 ++++++++++++++++++++++++++++++ pyproject.toml | 2 + tests/README.md | 9 + tests/btcd_oracle_test.py | 351 +++++++++++++++++++++ 10 files changed, 1274 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/btcd-oracle.yml create mode 100644 fuzz/btcd_decode/go.mod create mode 100644 fuzz/btcd_decode/go.sum create mode 100644 fuzz/btcd_decode/main.go create mode 100644 fuzz/btcd_oracle.py create mode 100644 tests/btcd_oracle_test.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a1e25e481..1d946955f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,7 +36,7 @@ # been public a week -- it means the offer comes at most a week after # that, rather than at most a month. No ecosystem here tracks anything # security-critical, so nothing here wants to be faster than that. The -# docker entry at the bottom declares it too, and says why it holds +# docker entry below declares it too, and says why it holds # nothing back there version: 2 updates: @@ -98,3 +98,18 @@ updates: patterns: ["*"] cooldown: default-days: 7 + + # fuzz/btcd_decode/go.mod pins the btcd wire release that + # btcd-oracle.yml compares btclib with. A pull request here is a newer + # release to run the weekly comparison against; the workflow is a + # sentinel, so nothing is gated on it. + - package-ecosystem: gomod + directory: /fuzz/btcd_decode + schedule: + interval: weekly + day: thursday + groups: + btcd: + patterns: ["*"] + cooldown: + default-days: 7 diff --git a/.github/workflows/btcd-oracle.yml b/.github/workflows/btcd-oracle.yml new file mode 100644 index 000000000..297eeaa89 --- /dev/null +++ b/.github/workflows/btcd-oracle.yml @@ -0,0 +1,119 @@ +--- +name: btcd oracle + +# Reads every input of the fuzz corpora with btcd's `wire` package and with +# btclib, and lists each one they read differently: one accepts and the +# other refuses, or both accept and write different octets back. +# `fuzz/btcd_oracle.py` is the comparison and `fuzz/btcd_decode` the btcd +# side, pinned to a release in its own go.mod. +# +# The inputs are `fuzz/corpus`, the seeds, and the `cifuzz-corpus-*` +# artifacts of the newest successful `fuzz` run, which are what the +# fuzzers grew from them. Artifacts expire, so a repository whose last +# `fuzz` run is old compares the seeds alone and says so. +# +# A sentinel and not a gate: the answer depends on btcd's releases and on +# what the fuzzers found this week, neither of which a pull request +# changes. A red run is a list of inputs to triage against Bitcoin Core, +# not a failure of the tree, so nothing requires this check. Differences +# already triaged are rules in `fuzz/btcd_oracle.py`, counted and not +# listed, so a run is red only for a disagreement no rule explains. The +# report is in the run's summary. +# +# The `pull_request` trigger is filtered to this file, the Go module and +# the script, so that a Dependabot bump of btcd, or an edit of either +# program, is built and run before it lands. Section 10 of the +# organization standard keeps such a trigger where a run adds seconds to +# the wait on a pull request's checks. This run is expected to end before +# `test`'s, whose median on a pull request was 195 seconds on 2026-10-11; +# that is confirmed on the first pull request's own run and not before. +# +# The schedule runs after `fuzz` (Tuesday 04, about 90 minutes), so that the +# artifacts it reads are that week's. + +on: + schedule: + # section 10's calendar in btclib-org/.github, and not this file's to + # restate. Only the default branch schedules workflows, so on any + # other branch this is reachable through the dispatch below + - cron: "4 1 * * 3" + workflow_dispatch: + pull_request: + # ready_for_review and closed for the reason vendored-vectors.yml's + # own trigger gives + types: [opened, reopened, synchronize, ready_for_review, closed] + paths: + - .github/workflows/btcd-oracle.yml + - fuzz/btcd_decode/** + - fuzz/btcd_oracle.py + +permissions: + contents: read + +# github.event.pull_request.number, not github.head_ref, for the reason +# vendored-vectors.yml's own concurrency block gives +concurrency: + group: btcd-oracle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ !(github.event.action == 'closed' && github.event.pull_request.merged) }} + +jobs: + compare: + name: Compare btclib with btcd over the fuzz corpora + if: ${{ !github.event.pull_request.draft && github.event.action != 'closed' }} + runs-on: ubuntu-latest + # far above what the build and the comparison take: what this bounds + # is a hung job + timeout-minutes: 20 + permissions: + contents: read + actions: read # gh run list, gh run download + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: true + # the `go` line of go.mod names the toolchain, so the Go release is + # pinned in the one file that also pins btcd's + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: fuzz/btcd_decode/go.mod + cache-dependency-path: fuzz/btcd_decode/go.sum + # the artifacts are the newest successful fuzz run's. None is not an + # error: the seeds are compared either way + - name: Download the corpora the last fuzz run grew + run: | + run_id=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow fuzz.yml --branch main --status success --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty') + mkdir -p "$RUNNER_TEMP/corpora" + if [ -z "$run_id" ]; then + echo "::warning::no successful fuzz run; comparing the seeds alone" + exit 0 + fi + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ + --pattern 'cifuzz-corpus-*' --dir "$RUNNER_TEMP/corpora" || + echo "::warning::fuzz run $run_id left no corpus artifact" + - name: Sync the environment + run: uv sync --locked --no-default-groups + # the script exits 1 when it lists an input no rule explains, which + # turns the job red; the report goes to the summary as well, where a + # reader opens it first + - name: Compare + run: | + set -o pipefail + status=0 + uv run --locked --no-default-groups python fuzz/btcd_oracle.py \ + fuzz/corpus "$RUNNER_TEMP/corpora" | tee report.txt || status=$? + { + echo '```text' + cat report.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$status" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a6b90893..44a766593 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -621,6 +621,7 @@ commit, which is then made again. | `deps-oldest` | weekly | the floor interpreter, deps at their floors | | `links`, `mutation` | weekly | — | | `vendored-vectors` | weekly | the pin ledgers | +| `btcd-oracle` | weekly, a pull request | the corpora, read by btcd | | `pypi-install` | weekly, a release | what PyPI serves | | `sdist-rebuild` | weekly | the latest release's sdist, rebuilt | | `py-arm-authority` | weekly, push to main | the arm-authority table | diff --git a/fuzz/btcd_decode/go.mod b/fuzz/btcd_decode/go.mod new file mode 100644 index 000000000..c79c57ed2 --- /dev/null +++ b/fuzz/btcd_decode/go.mod @@ -0,0 +1,13 @@ +module github.com/btclib-org/btclib/fuzz/btcd_decode + +go 1.25 + +require ( + github.com/btcsuite/btcd/chainhash/v2 v2.0.0 + github.com/btcsuite/btcd/wire/v2 v2.0.1 +) + +require ( + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/fuzz/btcd_decode/go.sum b/fuzz/btcd_decode/go.sum new file mode 100644 index 000000000..d195c3de4 --- /dev/null +++ b/fuzz/btcd_decode/go.sum @@ -0,0 +1,16 @@ +github.com/btcsuite/btcd/chainhash/v2 v2.0.0 h1:PMLlSloHJuEeB80XG9EjpXWNEKAZAMLl6YHZ6YsEuoA= +github.com/btcsuite/btcd/chainhash/v2 v2.0.0/go.mod h1:mKxcZ7oGTXE7IRV+sS9hP4EVBwc/SzfNR+52IsOP9j8= +github.com/btcsuite/btcd/wire/v2 v2.0.1 h1:edmb35tvRyQpFp331L9PwZFccaTCOQwevSsU6ra4BR4= +github.com/btcsuite/btcd/wire/v2 v2.0.1/go.mod h1:ENBpJL0JYUNlqvajhIFTOcWGNNumjCynDk4PL0OG58I= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/fuzz/btcd_decode/main.go b/fuzz/btcd_decode/main.go new file mode 100644 index 000000000..3988aa4fe --- /dev/null +++ b/fuzz/btcd_decode/main.go @@ -0,0 +1,260 @@ +// Command btcd_decode decodes files with btcd's wire package and prints, +// per file, whether btcd accepted them and the bytes it writes back. +// +// fuzz/btcd_oracle.py is its only caller and holds the other half of the +// comparison. Input is one JSON object per line on stdin, +// +// {"kind": "tx", "path": "fuzz/corpus/fuzz_tx/segwit.bin"} +// +// and so is the answer on stdout, +// +// {"kind": "tx", "path": "...", "status": "accepted", "hex": "0200..."} +// +// status is one of: +// +// accepted decoded, with every octet consumed; hex is the +// re-serialization +// refused btcd returned an error, or octets were left over; error +// says which +// unsupported btcd has no decoder for this kind or command; the +// comparison does not count it +// panic btcd panicked; error holds the value +// +// A version message may end before its relay flag, and the flag then reads +// as true: Bitcoin Core initializes fRelay to true and reads it only when +// octets remain (ProcessMessage, src/net_processing.cpp). btcd always +// writes the flag. A re-serialization that is the input plus a true flag +// is reported as the input, so that the comparison is modulo that field. +// +// Octets left over after a decode are a refusal, as in btclib, where an +// object is the whole of its octets. btcd's own Deserialize does not check. +// +// Every message is decoded at the latest protocol version btcd supports, +// with witness encoding: the encoding a current peer speaks. +package main + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +const ( + pver = wire.ProtocolVersion + enc = wire.WitnessEncoding + // Bitcoin Core's MAX_SIZE, the bound btclib's var_int applies. + maxVarBytes = 0x02000000 +) + +type request struct { + Kind string `json:"kind"` + Path string `json:"path"` +} + +type response struct { + Kind string `json:"kind"` + Path string `json:"path"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Hex string `json:"hex,omitempty"` +} + +// messages maps a kind to the btcd type that decodes it. Each constructor +// returns a fresh value, so that nothing is carried from one file to the +// next. +var messages = map[string]func() wire.Message{ + "tx": func() wire.Message { return &wire.MsgTx{} }, + "block": func() wire.Message { return &wire.MsgBlock{} }, + "merkleblock": func() wire.Message { return &wire.MsgMerkleBlock{} }, + "addr": func() wire.Message { return &wire.MsgAddr{} }, + "addrv2": func() wire.Message { return &wire.MsgAddrV2{} }, + "sendaddrv2": func() wire.Message { return &wire.MsgSendAddrV2{} }, + "version": func() wire.Message { return &wire.MsgVersion{} }, + "verack": func() wire.Message { return &wire.MsgVerAck{} }, + "inv": func() wire.Message { return &wire.MsgInv{} }, + "getdata": func() wire.Message { return &wire.MsgGetData{} }, + "notfound": func() wire.Message { return &wire.MsgNotFound{} }, + "getblocks": func() wire.Message { return &wire.MsgGetBlocks{} }, + "getheaders": func() wire.Message { return &wire.MsgGetHeaders{} }, + "headers": func() wire.Message { return &wire.MsgHeaders{} }, + "ping": func() wire.Message { return &wire.MsgPing{} }, + "pong": func() wire.Message { return &wire.MsgPong{} }, + "getaddr": func() wire.Message { return &wire.MsgGetAddr{} }, + "mempool": func() wire.Message { return &wire.MsgMemPool{} }, + "sendheaders": func() wire.Message { return &wire.MsgSendHeaders{} }, + "wtxidrelay": func() wire.Message { return &wire.MsgWTxIdRelay{} }, + "feefilter": func() wire.Message { return &wire.MsgFeeFilter{} }, + "reject": func() wire.Message { return &wire.MsgReject{} }, + "getcfilters": func() wire.Message { return &wire.MsgGetCFilters{} }, + "getcfheaders": func() wire.Message { + return &wire.MsgGetCFHeaders{} + }, + "getcfcheckpt": func() wire.Message { + return &wire.MsgGetCFCheckpt{} + }, + "cfilter": func() wire.Message { return &wire.MsgCFilter{} }, + "cfheaders": func() wire.Message { return &wire.MsgCFHeaders{} }, + "cfcheckpt": func() wire.Message { return &wire.MsgCFCheckpt{} }, + "filteradd": func() wire.Message { return &wire.MsgFilterAdd{} }, + "filterload": func() wire.Message { return &wire.MsgFilterLoad{} }, +} + +func refused(format string, args ...any) response { + return response{Status: "refused", Error: fmt.Sprintf(format, args...)} +} + +func accepted(out []byte) response { + return response{Status: "accepted", Hex: hex.EncodeToString(out)} +} + +// payload decodes data as the payload of one message type. +func payload(newMsg func() wire.Message, data []byte) response { + msg := newMsg() + // MsgVersion.BtcDecode wants a *bytes.Buffer. + r := bytes.NewBuffer(data) + if err := msg.BtcDecode(r, pver, enc); err != nil { + return refused("%v", err) + } + if r.Len() > 0 { + return refused("%d trailing octets", r.Len()) + } + var w bytes.Buffer + if err := msg.BtcEncode(&w, pver, enc); err != nil { + return refused("re-encoding: %v", err) + } + return accepted(omitRelay(msg, data, w.Bytes())) +} + +// omitRelay returns the input in place of its re-serialization when the two +// differ only by the true relay flag of a version message the input left +// out. +func omitRelay(msg wire.Message, in, out []byte) []byte { + if _, ok := msg.(*wire.MsgVersion); ok && len(out) == len(in)+1 && + bytes.Equal(out[:len(in)], in) && out[len(in)] == 1 { + return in + } + return out +} + +// message decodes data as one whole p2p message, envelope included. The +// network is read off the first four octets, because the question is +// whether the rest is a message, not whose. +func message(data []byte) response { + if len(data) < 4 { + return refused("shorter than a magic") + } + net := wire.BitcoinNet(binary.LittleEndian.Uint32(data)) + r := bytes.NewReader(data) + _, msg, in, err := wire.ReadMessageWithEncodingN(r, pver, net, enc) + if errors.Is(err, wire.ErrUnknownMessage) { + return response{Status: "unsupported", Error: err.Error()} + } + if err != nil { + return refused("%v", err) + } + if r.Len() > 0 { + return refused("%d trailing octets", r.Len()) + } + var w bytes.Buffer + if _, err := wire.WriteMessageWithEncodingN(&w, msg, pver, net, enc); err != nil { + return refused("re-encoding: %v", err) + } + out := w.Bytes() + if _, ok := msg.(*wire.MsgVersion); ok { + // the payload follows the 24 octet header; the length and the + // checksum are rewritten around the payload omitRelay returns + body := omitRelay(msg, in, out[wire.MessageHeaderSize:]) + header := append([]byte{}, out[:wire.MessageHeaderSize]...) + binary.LittleEndian.PutUint32(header[16:], uint32(len(body))) + copy(header[20:], chainhash.DoubleHashB(body)[:4]) + out = append(header, body...) + } + return accepted(out) +} + +func varInt(data []byte) response { + r := bytes.NewReader(data) + n, err := wire.ReadVarInt(r, pver) + if err != nil { + return refused("%v", err) + } + if r.Len() > 0 { + return refused("%d trailing octets", r.Len()) + } + var w bytes.Buffer + if err := wire.WriteVarInt(&w, pver, n); err != nil { + return refused("re-encoding: %v", err) + } + return accepted(w.Bytes()) +} + +func varBytes(data []byte) response { + r := bytes.NewReader(data) + b, err := wire.ReadVarBytes(r, pver, maxVarBytes, "var_bytes") + if err != nil { + return refused("%v", err) + } + if r.Len() > 0 { + return refused("%d trailing octets", r.Len()) + } + var w bytes.Buffer + if err := wire.WriteVarBytes(&w, pver, b); err != nil { + return refused("re-encoding: %v", err) + } + return accepted(w.Bytes()) +} + +func decode(req request) (res response) { + defer func() { + if p := recover(); p != nil { + res = response{Status: "panic", Error: fmt.Sprint(p)} + } + res.Kind, res.Path = req.Kind, req.Path + }() + + data, err := os.ReadFile(req.Path) + if err != nil { + return refused("%v", err) + } + switch newMsg, ok := messages[req.Kind]; { + case ok: + return payload(newMsg, data) + case req.Kind == "message": + return message(data) + case req.Kind == "var_int": + return varInt(data) + case req.Kind == "var_bytes": + return varBytes(data) + } + return response{Status: "unsupported", Error: "no btcd decoder for " + req.Kind} +} + +func main() { + in := bufio.NewScanner(os.Stdin) + in.Buffer(make([]byte, 0, 1<<20), 1<<20) + out := json.NewEncoder(os.Stdout) + for in.Scan() { + var req request + if err := json.Unmarshal(in.Bytes(), &req); err != nil { + fmt.Fprintln(os.Stderr, "bad request:", err) + os.Exit(2) + } + if err := out.Encode(decode(req)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + } + if err := in.Err(); err != nil && !errors.Is(err, io.EOF) { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } +} diff --git a/fuzz/btcd_oracle.py b/fuzz/btcd_oracle.py new file mode 100644 index 000000000..ea60f7e22 --- /dev/null +++ b/fuzz/btcd_oracle.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +# Copyright (c) The btclib developers +# Distributed under the MIT software license, see the accompanying +# LICENSE file or https://opensource.org/license/mit for the full text. + +"""Compare how btclib and btcd read the same octets. + +Every file under the directories named on the command line is decoded by +btclib, through each entry point the harness of its fuzzer lists, and by +btcd's `wire` package, through `fuzz/btcd_decode`. The two answers are +compared, and every input they read differently is listed: + +- one accepts and the other refuses; +- both accept and write different octets back; +- one crashes: a Python exception that is not a `BTClibException`, or a + Go panic. + +A directory is read as the corpus of the fuzzer it is named after, either +`fuzz_tx` or `cifuzz-corpus-fuzz_tx`, which is the name of the artifact +`fuzz.yml` uploads, so `fuzz/corpus` and a directory holding downloaded +artifacts are both given as they are. Every file below such a directory +is an input, and a `.tar` is unpacked first, which is how `gh run download` +leaves an artifact. + +An entry point btcd has no decoder for, and a message command that either +library does not know, are counted and not compared. + +A disagreement that a rule of `KNOWN` explains is counted under the rule +instead. The exit status is 1 when any input is listed, 0 +when none is. + + uv run --locked python fuzz/btcd_oracle.py fuzz/corpus +""" + +from __future__ import annotations + +import argparse +import ast +import importlib +import inspect +import json +import subprocess +import sys +import tarfile +import tempfile +from collections import Counter +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from btclib import var_int +from btclib.exceptions import BTClibException +from btclib.p2p.message import Message +from btclib.tx.tx import Tx + +_FUZZ = Path(__file__).resolve().parent +_DECODER_SOURCE = _FUZZ / "btcd_decode" +_ARTIFACT_PREFIX = "cifuzz-corpus-" +_SHOWN = 160 # characters of a serialization or a reason printed per outcome + +# Each entry point of the harnesses that btcd decodes, and the kind +# `btcd_decode` calls it. An entry point missing here has no btcd decoder. +KINDS = { + "btclib.tx.tx:Tx.parse": "tx", + "btclib.p2p.data:TxPayload.parse": "tx", + "btclib.block.block:Block.parse": "block", + "btclib.p2p.data:BlockPayload.parse": "block", + "btclib.p2p.message:Message.parse": "message", + "btclib.p2p.merkleblock:MerkleBlock.parse": "merkleblock", + "btclib.p2p.address:Addr.parse": "addr", + "btclib.p2p.addrv2:AddrV2.parse": "addrv2", + "btclib.p2p.addrv2:SendAddrV2.parse": "sendaddrv2", + "btclib.p2p.handshake:Version.parse": "version", + "btclib.p2p.handshake:Verack.parse": "verack", + "btclib.p2p.inventory:Inv.parse": "inv", + "btclib.p2p.inventory:GetData.parse": "getdata", + "btclib.p2p.inventory:NotFound.parse": "notfound", + "btclib.p2p.inventory:GetBlocks.parse": "getblocks", + "btclib.p2p.inventory:GetHeaders.parse": "getheaders", + "btclib.p2p.inventory:Headers.parse": "headers", + "btclib.p2p.keepalive:Ping.parse": "ping", + "btclib.p2p.keepalive:Pong.parse": "pong", + "btclib.p2p.negotiation:GetAddr.parse": "getaddr", + "btclib.p2p.negotiation:Mempool.parse": "mempool", + "btclib.p2p.negotiation:SendHeaders.parse": "sendheaders", + "btclib.p2p.negotiation:WtxidRelay.parse": "wtxidrelay", + "btclib.p2p.negotiation:FeeFilter.parse": "feefilter", + "btclib.p2p.reject:Reject.parse": "reject", + "btclib.p2p.block_filters:GetCFilters.parse": "getcfilters", + "btclib.p2p.block_filters:GetCFHeaders.parse": "getcfheaders", + "btclib.p2p.block_filters:GetCFCheckpt.parse": "getcfcheckpt", + "btclib.p2p.block_filters:CFilter.parse": "cfilter", + "btclib.p2p.block_filters:CFHeaders.parse": "cfheaders", + "btclib.p2p.block_filters:CFCheckpt.parse": "cfcheckpt", + "btclib.var_int:parse": "var_int", + "btclib.var_bytes:parse": "var_bytes", +} + +ACCEPTED = "accepted" +REFUSED = "refused" +UNSUPPORTED = "unsupported" +CRASH = "crash" + + +@dataclass(frozen=True) +class Outcome: + """What one library made of one input.""" + + status: str + detail: str = "" # the serialization in hex, or the reason + + +@dataclass(frozen=True) +class Rule: + """A class of disagreement that is understood, and why.""" + + name: str + kinds: tuple[str, ...] + why: str # one line, with the Bitcoin Core line it rests on + matches: Callable[[Outcome, Outcome, bytes], bool] # (btclib, btcd, input) + + +def _read_var_int(octets: bytes, at: int) -> tuple[int, int]: + """Return the CompactSize at `at` and the index after it.""" + first = octets[at] + if first < 0xFD: + return first, at + 1 + width = {0xFD: 2, 0xFE: 4, 0xFF: 8}[first] + value = int.from_bytes(octets[at + 1 : at + 1 + width], "little") + return value, at + 1 + width + + +def _without_unknown_networks(serialized: str) -> str: + """Return an addrv2 serialization without the entries of ids outside 1-6. + + An entry is the time, the services as a CompactSize, the network id, + the address as var_bytes and the port. + """ + octets = bytes.fromhex(serialized) + kept = [] + try: + count, at = _read_var_int(octets, 0) + for _ in range(count): + start = at + _, at = _read_var_int(octets, at + 4) + network = octets[at] + size, at = _read_var_int(octets, at + 1) + at += size + 2 + if 1 <= network <= 6: + kept.append(octets[start:at]) + except (IndexError, KeyError): + return "" # not an addrv2 serialization: equal to nothing + return (var_int.serialize(len(kept)) + b"".join(kept)).hex() + + +# Triaged against Bitcoin Core at a8b69bdeaf. A disagreement one of these +# explains is counted under its rule and does not fail the run; any other +# does. None of them is a defect of btclib. Each is keyed by what the two +# libraries answered, never by a list of inputs. +KNOWN = ( + Rule( + "version-fields-optional", + ("version",), + "btclib requires addr_from through start_height, which Core reads only" + " where octets remain (handshake.py's docstring; net_processing.cpp" + " 3884-3899); btcd also accepts addr_from without the nonce, which" + " Core refuses (3889-3890)", + lambda ours, theirs, data: ( + ours.status == REFUSED + and theirs.status == ACCEPTED + and ours.detail.startswith("BTClibValueError: not enough") + and len(theirs.detail) > len(data) * 2 + and theirs.detail.startswith(data.hex()) + ), + ), + Rule( + "relay-flag-byte", + ("version",), + "Core reads the relay flag as any byte (serialize.h line 280);" + " btclib refuses a byte above 1, which it could not write back", + lambda ours, theirs, _: ( + ours.status == REFUSED + and theirs.status == ACCEPTED + and "invalid relay flag" in ours.detail + ), + ), + Rule( + "var-int-above-max-size", + ("var_int",), + "btcd has no range check; Core refuses above MAX_SIZE" + " (serialize.h line 359), as btclib does", + lambda ours, theirs, _: ( + ours.status == REFUSED + and theirs.status == ACCEPTED + and "var_int too big" in ours.detail + ), + ), + Rule( + "zero-flag-after-empty-vin", + ("tx", "block"), + "btcd refuses a zero flag byte after an empty input list; Core reads" + " an empty transaction (primitives/transaction.h lines 212-218)", + lambda ours, theirs, data: ( + ours.status == ACCEPTED + and ours.detail == data.hex() + and theirs.status == REFUSED + and "flag byte is 00" in theirs.detail + ), + ), + Rule( + "addrv2-unknown-network-dropped", + ("addrv2",), + "Core ignores an address of a network id outside 1-6 and btclib keeps" + " its octets (netaddress.h line 470); btcd drops them as Core does", + lambda ours, theirs, _: ( + ours.status == theirs.status == ACCEPTED + and theirs.detail == _without_unknown_networks(ours.detail) + ), + ), +) + + +@dataclass(frozen=True) +class Job: + """One input read through one entry point.""" + + fuzzer: str + entry: str + kind: str + path: Path + label: str # the directory's name and the path below it + + +def _resolve(entry: str) -> Any: + module_name, _, qualname = entry.partition(":") + obj: Any = importlib.import_module(module_name) + for name in qualname.split("."): + obj = getattr(obj, name) + return obj + + +def entry_points(harness: Path) -> tuple[str, ...]: + """Return the `ENTRY_POINTS` of a harness, read without importing it.""" + for node in ast.parse(harness.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "ENTRY_POINTS" for t in node.targets + ): + value = ast.literal_eval(node.value) + return tuple(value) + err_msg = f"{harness} declares no ENTRY_POINTS" + raise SystemExit(err_msg) + + +def fuzzer_directories(roots: list[Path]) -> dict[Path, str]: + """Return every directory under the roots that holds one fuzzer's inputs.""" + found: dict[Path, str] = {} + for root in roots: + for directory in [root, *sorted(p for p in root.rglob("*") if p.is_dir())]: + name = directory.name.removeprefix(_ARTIFACT_PREFIX) + if (_FUZZ / f"{name}.py").is_file() and name.startswith("fuzz_"): + found[directory] = name + return found + + +def unpack(roots: list[Path], into: Path) -> list[Path]: + """Return the roots, with each `.tar` below them unpacked into `into`.""" + unpacked = list(roots) + for root in roots: + for archive in sorted(root.rglob("*.tar")): + target = into / archive.stem + target.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive) as tar: + tar.extractall(target, filter="data") + unpacked.append(target) + return unpacked + + +def _commands() -> dict[str, Any]: + """Return the btclib payload class of each command it has one for.""" + classes = {} + for entry, kind in KINDS.items(): + if kind in ("message", "var_int", "var_bytes"): + continue + cls = _resolve(entry.rpartition(".")[0]) + if hasattr(cls, "command"): + classes[cls.command] = cls + return classes + + +def _structural(function: Any, *args: Any) -> Any: + """Call `function`, with `check_validity=False` where it takes the flag. + + btcd's decoders read a structure and leave its validity to the caller: + a block's proof of work and merkle root, a transaction's duplicate + inputs. btclib's `parse` checks them by default, so the comparison turns + that off to ask the same question. + """ + if "check_validity" in inspect.signature(function).parameters: + return function(*args, check_validity=False) + return function(*args) + + +def read_with_btclib(entry: str, data: bytes, commands: dict[str, Any]) -> Outcome: + """Return what btclib makes of `data` through `entry`.""" + kind = KINDS.get(entry) + if kind is None: + return Outcome(UNSUPPORTED, "no btcd decoder") + try: + if kind == "message": + message = Message.parse(data) + cls = commands.get(message.command) + if cls is None: + return Outcome( + UNSUPPORTED, f"btclib has no class for {message.command!r}" + ) + payload = _structural(_structural(cls.parse, message.payload).serialize) + return Outcome( + ACCEPTED, + Message(message.magic, message.command, payload).serialize().hex(), + ) + parse = _resolve(entry) + parsed = _structural(parse, data) + if kind == "var_int": + out = _resolve("btclib.var_int:serialize")(parsed) + elif kind == "var_bytes": + out = _resolve("btclib.var_bytes:serialize")(parsed) + elif isinstance(parsed, Tx): + out = _structural(parsed.serialize, True) + else: + out = _structural(parsed.serialize) + except BTClibException as exc: + return Outcome(REFUSED, f"{type(exc).__name__}: {exc}") + except Exception as exc: # noqa: BLE001 -- a crash is what this reports + return Outcome(CRASH, f"{type(exc).__name__}: {exc}") + return Outcome(ACCEPTED, out.hex()) + + +def build_decoder(out_dir: Path) -> Path: + """Build `fuzz/btcd_decode` into `out_dir` and return the executable.""" + executable = out_dir / "btcd_decode" + subprocess.run( # noqa: S603 -- a fixed command + ["go", "build", "-o", str(executable), "."], # noqa: S607 + cwd=_DECODER_SOURCE, + check=True, + ) + return executable + + +def read_with_btcd(decoder: Path, jobs: list[Job]) -> list[Outcome]: + """Return what btcd makes of each job, in order.""" + requests = "".join( + json.dumps({"kind": job.kind, "path": str(job.path)}) + "\n" for job in jobs + ) + done = subprocess.run( # noqa: S603 -- the decoder the caller named + [str(decoder)], + input=requests, + capture_output=True, + encoding="utf-8", + check=True, + ) + outcomes = [] + for line in done.stdout.splitlines(): + answer = json.loads(line) + status = {"panic": CRASH}.get(answer["status"], answer["status"]) + detail = answer.get("hex") if status == ACCEPTED else answer.get("error", "") + outcomes.append(Outcome(status, detail or "")) + if len(outcomes) != len(jobs): + err_msg = f"btcd_decode answered {len(outcomes)} of {len(jobs)} inputs" + raise SystemExit(err_msg) + return outcomes + + +def _first_difference(a: str, b: str) -> int: + """Return the index of the first octet two hex strings differ at.""" + octets = min(len(a), len(b)) // 2 + return next( + (i for i in range(octets) if a[2 * i : 2 * i + 2] != b[2 * i : 2 * i + 2]), + octets, + ) + + +def disagreement(ours: Outcome, theirs: Outcome) -> str | None: + """Return how the two outcomes differ, or None when they agree.""" + if UNSUPPORTED in (ours.status, theirs.status): + return None + if CRASH in (ours.status, theirs.status): + return "crash" + if ours.status != theirs.status: + return f"btclib {ours.status}, btcd {theirs.status}" + if ours.status == ACCEPTED and ours.detail != theirs.detail: + return f"both accept, serializations differ at octet {_first_difference(ours.detail, theirs.detail)}" + return None + + +def explained_by(kind: str, ours: Outcome, theirs: Outcome, data: bytes) -> Rule | None: + """Return the rule that explains this disagreement, if one does.""" + return next( + (r for r in KNOWN if kind in r.kinds and r.matches(ours, theirs, data)), None + ) + + +def collect_jobs(roots: list[Path]) -> tuple[list[Job], Counter[str]]: + """Return one job per input and entry point, and the entry points left.""" + jobs = [] + skipped: Counter[str] = Counter() + for directory, fuzzer in sorted(fuzzer_directories(roots).items()): + entries = entry_points(_FUZZ / f"{fuzzer}.py") + files = sorted( + p for p in directory.rglob("*") if p.is_file() and p.suffix != ".tar" + ) + for entry in entries: + kind = KINDS.get(entry) + if kind is None: + skipped[entry] += len(files) + continue + jobs.extend( + Job( + fuzzer, + entry, + kind, + path, + f"{directory.name}/{path.relative_to(directory)}", + ) + for path in files + ) + return jobs, skipped + + +def main(argv: list[str] | None = None) -> int: + """List the inputs btclib and btcd read differently.""" + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("roots", nargs="+", type=Path, help="corpus directories") + parser.add_argument( + "--decoder", + type=Path, + help="a built fuzz/btcd_decode; built with `go build` when absent", + ) + args = parser.parse_args(argv) + + with tempfile.TemporaryDirectory() as tmp: + return _compare(args, Path(tmp)) + + +def _compare(args: argparse.Namespace, tmp: Path) -> int: + jobs, skipped = collect_jobs(unpack(args.roots, tmp / "unpacked")) + if not jobs: + err_msg = "no input found: name a directory called fuzz_ or cifuzz-corpus-fuzz_" + raise SystemExit(err_msg) + commands = _commands() + ours = [ + read_with_btclib(job.entry, job.path.read_bytes(), commands) for job in jobs + ] + decoder = args.decoder or build_decoder(tmp) + theirs = read_with_btcd(decoder, jobs) + + listed = 0 + known: Counter[str] = Counter() + compared: Counter[str] = Counter() + for job, a, b in zip(jobs, ours, theirs, strict=True): + if UNSUPPORTED in (a.status, b.status): + skipped[job.entry] += 1 + continue + compared[job.entry] += 1 + why = disagreement(a, b) + if why is None: + continue + rule = explained_by(job.kind, a, b, job.path.read_bytes()) + if rule is not None: + known[rule.name] += 1 + continue + listed += 1 + print(f"{job.label} [{job.entry}] {why}") # noqa: T201 + for name, outcome in (("btclib", a), ("btcd", b)): + print(f" {name:6} {outcome.status}: {outcome.detail[:_SHOWN]}") # noqa: T201 + + print(f"compared {sum(compared.values())} reads, listed {listed}") # noqa: T201 + for rule in KNOWN: + if known[rule.name]: + print(f"known: {rule.name} x{known[rule.name]}: {rule.why}") # noqa: T201 + for entry, n in sorted(skipped.items()): + print(f"not compared: {entry} x{n}") # noqa: T201 + return 1 if listed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index d2fc78f93..25bece594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -363,6 +363,8 @@ source-exclude = [ # fuzz/fuzz_*.py and fuzz/corpus/ off the tree, both absent from an # unpacked sdist "/tests/fuzz_corpus_test.py", + # the same reasoning: this one loads fuzz/btcd_oracle.py + "/tests/btcd_oracle_test.py", # a third shape of the same reasoning: this one reaches outside the # sdist through `.git` rather than through a tracked directory, and # what it reads (`git show :...`, a release's own tag) has no diff --git a/tests/README.md b/tests/README.md index 1e0c9e451..ed3435ecc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -200,6 +200,15 @@ calls `_raw_upload_directory` and does not — so the two names are not the same shape, and a download asking for the wrong one answers `no artifact matches any of the names or patterns provided`. +`btcd-oracle.yml` reads those artifacts, and the seeds, with btcd's `wire` +package as well as btclib, and lists every input the two read differently. +To run it by hand, with `gh run download --dir ` having left the +artifacts in ``: + +```shell +uv run --locked python fuzz/btcd_oracle.py fuzz/corpus +``` + ## There is no `slow` marker, and that is a measurement `addopts` in pyproject.toml passes `--strict-markers`, so a marker has to diff --git a/tests/btcd_oracle_test.py b/tests/btcd_oracle_test.py new file mode 100644 index 000000000..9856cd384 --- /dev/null +++ b/tests/btcd_oracle_test.py @@ -0,0 +1,351 @@ +# Copyright (c) The btclib developers +# Distributed under the MIT software license, see the accompanying +# LICENSE file or https://opensource.org/license/mit for the full text. + +"""`fuzz/btcd_oracle.py` lists the inputs btclib and btcd read differently. + +The Go side is not run here: it needs a toolchain and the network, and +`btcd-oracle.yml` runs it. What is held here is the Python side, with a +stand-in decoder that answers from a fixed table. +""" + +from __future__ import annotations + +import importlib.util +import io +import json +import subprocess +import sys +import tarfile +from pathlib import Path +from types import ModuleType + +import pytest + +from btclib.hashes import hash256 + +_FUZZ = Path(__file__).parent.parent / "fuzz" + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "btcd_oracle", _FUZZ / "btcd_oracle.py" + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +oracle = _load() + +_SEGWIT_TX = (_FUZZ / "corpus" / "fuzz_tx" / "segwit.bin").read_bytes() +_MAGIC = bytes.fromhex("f9beb4d9") + + +def _fake_decoder( + monkeypatch: pytest.MonkeyPatch, answers: list[dict[str, str]] +) -> str: + """Make btcd's side answer `answers` and return the path to name it by.""" + + def run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + stdout = "".join(json.dumps(a) + "\n" for a in answers) + return subprocess.CompletedProcess(command, 0, stdout=stdout) + + monkeypatch.setattr(oracle.subprocess, "run", run) + return "decoder" + + +def test_every_kind_names_an_entry_point_of_a_harness() -> None: + """A kind of the table is an entry point some harness declares.""" + declared = { + entry + for harness in _FUZZ.glob("fuzz_*.py") + for entry in oracle.entry_points(harness) + } + assert set(oracle.KINDS) <= declared + + +def test_a_harness_without_entry_points_is_refused(tmp_path: Path) -> None: + """A harness that declares no entry point is an error.""" + harness = tmp_path / "fuzz_none.py" + harness.write_text("X = 1\n") + with pytest.raises(SystemExit, match="declares no ENTRY_POINTS"): + oracle.entry_points(harness) + + +@pytest.mark.parametrize( + "ours, theirs, expected", + [ + (("accepted", "00"), ("accepted", "00"), None), + ( + ("accepted", "00"), + ("accepted", "01"), + "both accept, serializations differ at octet 0", + ), + ( + ("accepted", "00"), + ("accepted", "0001"), + "both accept, serializations differ at octet 1", + ), + (("accepted", "00"), ("refused", "x"), "btclib accepted, btcd refused"), + (("refused", "x"), ("accepted", "00"), "btclib refused, btcd accepted"), + (("refused", "x"), ("refused", "y"), None), + (("crash", "x"), ("refused", "y"), "crash"), + (("accepted", "00"), ("crash", "y"), "crash"), + (("unsupported", ""), ("accepted", "00"), None), + (("accepted", "00"), ("unsupported", ""), None), + ], +) +def test_disagreement( + ours: tuple[str, str], theirs: tuple[str, str], expected: str | None +) -> None: + """Each pair of outcomes is told apart as the report words it.""" + assert ( + oracle.disagreement(oracle.Outcome(*ours), oracle.Outcome(*theirs)) == expected + ) + + +def _message(command: bytes, payload: bytes) -> bytes: + return ( + _MAGIC + + command.ljust(12, b"\x00") + + len(payload).to_bytes(4, "little") + + hash256(payload)[:4] + + payload + ) + + +@pytest.mark.parametrize( + "entry, data, status", + [ + ("btclib.tx.tx:Tx.parse", _SEGWIT_TX, "accepted"), + ("btclib.tx.tx:Tx.parse", b"\x00", "refused"), + ("btclib.var_int:parse", b"\x01", "accepted"), + ("btclib.var_bytes:parse", b"\x01\x02", "accepted"), + ("btclib.script.script:parse", b"", "unsupported"), + ("btclib.p2p.message:Message.parse", _message(b"verack", b""), "accepted"), + ( + "btclib.p2p.message:Message.parse", + _message(b"cmpctblock", b""), + "unsupported", + ), + ("btclib.p2p.message:Message.parse", b"", "refused"), + ], +) +def test_btclib_side(entry: str, data: bytes, status: str) -> None: + """Each entry point is accepted, refused or unsupported by btclib.""" + outcome = oracle.read_with_btclib(entry, data, oracle._commands()) + assert outcome.status == status + + +def test_btclib_side_reports_a_crash(monkeypatch: pytest.MonkeyPatch) -> None: + """An exception that is not a BTClibException is a crash.""" + + def boom(_: bytes) -> None: + raise IndexError + + monkeypatch.setattr("btclib.var_int.parse", boom) + outcome = oracle.read_with_btclib("btclib.var_int:parse", b"\x01", {}) + assert outcome.status == "crash" + + +def test_tar_artifacts_are_unpacked(tmp_path: Path) -> None: + """A downloaded artifact's tar is read as its fuzzer's inputs.""" + root = tmp_path / "downloaded" / "cifuzz-corpus-fuzz_var_int" + root.mkdir(parents=True) + with tarfile.open(root / "cifuzz-corpus-fuzz_var_int.tar", "w") as tar: + info = tarfile.TarInfo("0000") + info.size = 1 + tar.addfile(info, io.BytesIO(b"\x01")) + roots = oracle.unpack([tmp_path / "downloaded"], tmp_path / "out") + jobs, skipped = oracle.collect_jobs(roots) + assert [job.path.name for job in jobs] == ["0000"] + assert not skipped + + +def test_main_lists_what_the_two_read_differently( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A disagreement is printed and the exit status is 1.""" + corpus = tmp_path / "fuzz_var_int" + corpus.mkdir() + (corpus / "one.bin").write_bytes(b"\x01") + decoder = _fake_decoder( + monkeypatch, + [{"kind": "var_int", "path": "x", "status": "refused", "error": "no"}], + ) + assert oracle.main([str(corpus), "--decoder", decoder]) == 1 + out = capsys.readouterr().out + assert "btclib accepted, btcd refused" in out + assert "listed 1" in out + + +def test_main_exits_zero_when_they_agree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Agreement prints nothing but the totals and exits 0.""" + corpus = tmp_path / "fuzz_var_int" + corpus.mkdir() + (corpus / "one.bin").write_bytes(b"\x01") + decoder = _fake_decoder( + monkeypatch, + [{"kind": "var_int", "path": "x", "status": "accepted", "hex": "01"}], + ) + assert oracle.main([str(corpus), "--decoder", decoder]) == 0 + assert "not compared: btclib.var_int" not in capsys.readouterr().out + + +def test_main_refuses_a_decoder_that_skips_an_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A decoder answering fewer inputs than asked is an error.""" + corpus = tmp_path / "fuzz_var_int" + corpus.mkdir() + (corpus / "one.bin").write_bytes(b"\x01") + decoder = _fake_decoder(monkeypatch, []) + with pytest.raises(SystemExit, match="answered 0 of 1"): + oracle.main([str(corpus), "--decoder", decoder]) + + +def test_main_refuses_a_directory_with_no_fuzzer(tmp_path: Path) -> None: + """A directory named after no fuzzer holds no input.""" + with pytest.raises(SystemExit, match="no input found"): + oracle.main([str(tmp_path)]) + + +_TIME = "00000000" +_PORT = "0000" + + +def _addrv2(*entries: tuple[str, str, str]) -> str: + """Return an addrv2 serialization of (services, network id, address).""" + out = f"{len(entries):02x}" + for services, network, address in entries: + size = f"{len(address) // 2:02x}" + out += _TIME + services + network + size + address + _PORT + return out + + +_IPV4 = ("00", "01", "7f000001") +_I2P = ("00", "05", "00" * 32) +_UNKNOWN = ("00", "09", "ab") +_VERSION = "62ea0000" + "00" * 90 + + +@pytest.mark.parametrize( + "kind, ours, theirs, data, rule", + [ + ( + "version", + ("refused", "BTClibValueError: not enough data for the nonce"), + ("accepted", _VERSION + "00"), + _VERSION, + "version-fields-optional", + ), + ( + "version", + ("refused", "BTClibValueError: not enough data for the nonce"), + ("accepted", _VERSION), + _VERSION, + None, + ), + ( + "version", + ("refused", "BTClibValueError: not enough data for the nonce"), + ("accepted", "ff" + _VERSION + "00"), + _VERSION, + None, + ), + ( + "version", + ("refused", "BTClibValueError: invalid relay flag: 20"), + ("accepted", "00"), + "00", + "relay-flag-byte", + ), + ( + "var_int", + ("refused", "BTClibValueError: var_int too big: 00"), + ("accepted", "00"), + "00", + "var-int-above-max-size", + ), + ( + "tx", + ("accepted", "0102"), + ("refused", "MsgTx.BtcDecode: witness tx but flag byte is 00"), + "0102", + "zero-flag-after-empty-vin", + ), + ( + "tx", + ("accepted", "0103"), + ("refused", "MsgTx.BtcDecode: witness tx but flag byte is 00"), + "0102", + None, + ), + ( + "addrv2", + ("accepted", _addrv2(_IPV4, _UNKNOWN)), + ("accepted", _addrv2(_IPV4)), + "", + "addrv2-unknown-network-dropped", + ), + ( + "addrv2", + ("accepted", _addrv2(_I2P)), + ("accepted", _addrv2()), + "", + None, + ), + ( + "addrv2", + ("accepted", _addrv2(_IPV4)), + ("accepted", _addrv2()), + "", + None, + ), + ("addrv2", ("accepted", "01"), ("accepted", "00"), "", None), + ("tx", ("refused", "x"), ("accepted", "00"), "", None), + ( + "message", + ("refused", "BTClibValueError: not enough"), + ("accepted", "0"), + "", + None, + ), + ], +) +def test_known_differences( + kind: str, + ours: tuple[str, str], + theirs: tuple[str, str], + data: str, + rule: str | None, +) -> None: + """A triaged class is explained by its rule, and by no other.""" + found = oracle.explained_by( + kind, + oracle.Outcome(*ours), + oracle.Outcome(*theirs), + bytes.fromhex(data), + ) + assert (found.name if found else None) == rule + + +def test_main_counts_a_known_difference_and_exits_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A rule's disagreement is counted under it and does not fail the run.""" + corpus = tmp_path / "fuzz_var_int" + corpus.mkdir() + (corpus / "big.bin").write_bytes(bytes.fromhex("fe00000003")) + decoder = _fake_decoder( + monkeypatch, + [{"kind": "var_int", "path": "x", "status": "accepted", "hex": "fe00000003"}], + ) + assert oracle.main([str(corpus), "--decoder", decoder]) == 0 + assert "known: var-int-above-max-size x1" in capsys.readouterr().out From 5f7e483c9768b6695902d220cead2464491b62f1 Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Sun, 11 Oct 2026 13:27:38 +0200 Subject: [PATCH 2/6] Raise golang.org/x/crypto to 0.52.0 in the btcd decoder fuzz/btcd_decode/go.mod reached golang.org/x/crypto 0.40.0 through btcd's wire/v2 (golang.org/x/crypto/sha3), and the Dependency review check refused it. 0.52.0 is the lowest release that clears all fifteen advisories the check listed, whose patched versions are 0.45.0 for GHSA-f6x5-jh6r-wrfv and GHSA-j5w8-q4qc-rx2x, and 0.52.0 for GHSA-45gg-vh54-h5m9, GHSA-5cgq-3rg8-m6cv, GHSA-78mq-xcr3-xm33, GHSA-89gr-r52h-f8rx, GHSA-9m57-25v3-79x9, GHSA-f5wc-c3c7-36mc, GHSA-jppx-rxg9-jmrx, GHSA-q4h4-gmj2-qvw2, GHSA-qpw4-5x99-6vjp, GHSA-rm3j-f69w-wqmq, GHSA-vgwf-h737-ff37, GHSA-w879-237q-wc7r and GHSA-x527-x647-q7gg. golang.org/x/sys moves to 0.45.0, the release 0.52.0 requires, and the go line to 1.25.0. Both releases are from 2026-05-21 and 2026-05-22. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ferdinando Ametrano --- fuzz/btcd_decode/go.mod | 6 +++--- fuzz/btcd_decode/go.sum | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fuzz/btcd_decode/go.mod b/fuzz/btcd_decode/go.mod index c79c57ed2..e32cdab36 100644 --- a/fuzz/btcd_decode/go.mod +++ b/fuzz/btcd_decode/go.mod @@ -1,6 +1,6 @@ module github.com/btclib-org/btclib/fuzz/btcd_decode -go 1.25 +go 1.25.0 require ( github.com/btcsuite/btcd/chainhash/v2 v2.0.0 @@ -8,6 +8,6 @@ require ( ) require ( - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect ) diff --git a/fuzz/btcd_decode/go.sum b/fuzz/btcd_decode/go.sum index d195c3de4..407bf7ff5 100644 --- a/fuzz/btcd_decode/go.sum +++ b/fuzz/btcd_decode/go.sum @@ -8,9 +8,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 223f45d4d0b2f4ccba5f4365c0aa6a8ead4aa848 Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Sun, 11 Oct 2026 14:23:42 +0200 Subject: [PATCH 3/6] Pin the Go toolchain of the btcd decoder to go1.27.1 The go line of fuzz/btcd_decode/go.mod is 1.25.0 since the x/crypto bump, and setup-go reads that line, so CI installed exactly go1.25.0, a release no longer supported. setup-go reads the toolchain line first, so a toolchain line names the release. go1.27.1 is the newest patch of the supported 1.27 series that is at least seven days old; go1.27.2 was tagged on 2026-10-08. Dependabot's gomod updater reads the toolchain from the environment only, and does not move the line. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ferdinando Ametrano --- .github/workflows/btcd-oracle.yml | 5 +++-- fuzz/btcd_decode/go.mod | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/btcd-oracle.yml b/.github/workflows/btcd-oracle.yml index 297eeaa89..a1e0a49ff 100644 --- a/.github/workflows/btcd-oracle.yml +++ b/.github/workflows/btcd-oracle.yml @@ -78,8 +78,9 @@ jobs: uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true - # the `go` line of go.mod names the toolchain, so the Go release is - # pinned in the one file that also pins btcd's + # the `toolchain` line of go.mod names the Go release, so it is pinned in + # the one file that also pins btcd's; Dependabot's gomod updater does not + # move that line - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: diff --git a/fuzz/btcd_decode/go.mod b/fuzz/btcd_decode/go.mod index e32cdab36..783c0807f 100644 --- a/fuzz/btcd_decode/go.mod +++ b/fuzz/btcd_decode/go.mod @@ -2,6 +2,8 @@ module github.com/btclib-org/btclib/fuzz/btcd_decode go 1.25.0 +toolchain go1.27.1 + require ( github.com/btcsuite/btcd/chainhash/v2 v2.0.0 github.com/btcsuite/btcd/wire/v2 v2.0.1 From 5f63017af80d73f464ebfdf399ac3d93d47ca19c Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Sun, 11 Oct 2026 17:14:30 +0200 Subject: [PATCH 4/6] Drop the copied schedule and the dated timing from btcd-oracle.yml The schedule comment named fuzz.yml's day, hour and timeout, which are section 10's calendar and not this file's. It now says only that the run follows `fuzz`'s. The trigger comment keeps its reason, that the run ends before `test`'s, and drops a timing no gate re-derives and a note about the first run that would become history once landed. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ferdinando Ametrano --- .github/workflows/btcd-oracle.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/btcd-oracle.yml b/.github/workflows/btcd-oracle.yml index a1e0a49ff..75fd5788f 100644 --- a/.github/workflows/btcd-oracle.yml +++ b/.github/workflows/btcd-oracle.yml @@ -25,11 +25,10 @@ name: btcd oracle # program, is built and run before it lands. Section 10 of the # organization standard keeps such a trigger where a run adds seconds to # the wait on a pull request's checks. This run is expected to end before -# `test`'s, whose median on a pull request was 195 seconds on 2026-10-11; -# that is confirmed on the first pull request's own run and not before. +# `test`'s. # -# The schedule runs after `fuzz` (Tuesday 04, about 90 minutes), so that the -# artifacts it reads are that week's. +# The schedule runs after `fuzz`'s, so that the artifacts it reads are that +# week's. on: schedule: From b2d5d742b22f7a82db62e6ba82682c8fb1cf9b45 Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Sun, 11 Oct 2026 17:23:40 +0200 Subject: [PATCH 5/6] Say what the btcd oracle lists and what running it by hand needs The README said the oracle lists every input the two decoders read differently. Differences a rule in `fuzz/btcd_oracle.py` explains are counted and not listed, so the sentence now says so. The by-hand command runs `go build`, so it needs a Go toolchain on `PATH`. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ferdinando Ametrano --- tests/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index ed3435ecc..eb1053a7a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -201,8 +201,9 @@ the same shape, and a download asking for the wrong one answers `no artifact matches any of the names or patterns provided`. `btcd-oracle.yml` reads those artifacts, and the seeds, with btcd's `wire` -package as well as btclib, and lists every input the two read differently. -To run it by hand, with `gh run download --dir ` having left the +package as well as btclib, and lists each input the two read differently +that no rule in `fuzz/btcd_oracle.py` explains. To run it by hand, with a Go +toolchain on `PATH` and `gh run download --dir ` having left the artifacts in ``: ```shell From c58fd81ee30b9ef298568691399a6c0631d86a80 Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Sun, 11 Oct 2026 17:37:56 +0200 Subject: [PATCH 6/6] Stop saying a pull request cannot change the btcd oracle's answer The header said the answer depends on btcd's releases and the fuzzers' finds, neither of which a pull request changes. A Dependabot bump of btcd changes the first, and a change to btclib's parsers changes the answer too. The header now names only what can turn the run red with no change to the tree. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ferdinando Ametrano --- .github/workflows/btcd-oracle.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/btcd-oracle.yml b/.github/workflows/btcd-oracle.yml index 75fd5788f..a64d8b144 100644 --- a/.github/workflows/btcd-oracle.yml +++ b/.github/workflows/btcd-oracle.yml @@ -12,13 +12,12 @@ name: btcd oracle # fuzzers grew from them. Artifacts expire, so a repository whose last # `fuzz` run is old compares the seeds alone and says so. # -# A sentinel and not a gate: the answer depends on btcd's releases and on -# what the fuzzers found this week, neither of which a pull request -# changes. A red run is a list of inputs to triage against Bitcoin Core, -# not a failure of the tree, so nothing requires this check. Differences -# already triaged are rules in `fuzz/btcd_oracle.py`, counted and not -# listed, so a run is red only for a disagreement no rule explains. The -# report is in the run's summary. +# A sentinel and not a gate: what the fuzzers found this week can turn it +# red with no change to the tree. A red run is a list of inputs to +# triage against Bitcoin Core, not a failure of the tree, so nothing +# requires this check. Differences already triaged are rules in +# `fuzz/btcd_oracle.py`, counted and not listed, so a run is red only for +# a disagreement no rule explains. The report is in the run's summary. # # The `pull_request` trigger is filtered to this file, the Go module and # the script, so that a Dependabot bump of btcd, or an edit of either