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..a64d8b144 --- /dev/null +++ b/.github/workflows/btcd-oracle.yml @@ -0,0 +1,118 @@ +--- +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: 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 +# 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. +# +# The schedule runs after `fuzz`'s, 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 `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: + 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..783c0807f --- /dev/null +++ b/fuzz/btcd_decode/go.mod @@ -0,0 +1,15 @@ +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 +) + +require ( + 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 new file mode 100644 index 000000000..407bf7ff5 --- /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.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= 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..eb1053a7a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -200,6 +200,16 @@ 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 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 +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