diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index f90c5a1d2f..84d5fc5f0b 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,3 +1,5 @@ # Optional host client. Not a mutants gate. Same stance as coverage. # cargo-mutants ignores this file when the command line passes --exclude. exclude_globs = ["crates/rbitcoin-bench/**/*.rs"] +# The skip attributes compile only with this feature. Default node builds leave it off. +additional_cargo_args = "--features mutants" diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ec19c5f8c1..429ad1f15c 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -103,3 +103,49 @@ jobs: name: fuzz-crashers-${{ matrix.bin }} path: fuzz/crashers/ if-no-files-found: ignore + + # Sunday, or a manual dispatch. The daily cron checks out and exits. + ubsan: + name: ubsan ${{ matrix.bin }} + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + RUSTUP_TOOLCHAIN: nightly + RUSTFLAGS: "-A warnings" + strategy: + fail-fast: false + matrix: + bin: [asmap, block_wire] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: false + + - name: sunday or dispatch + id: gate + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" || "$(date -u +%u)" == "7" ]]; then + echo "run=1" >> "$GITHUB_OUTPUT" + fi + + - if: steps.gate.outputs.run == '1' + uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # nightly + with: + components: rust-src + + - if: steps.gate.outputs.run == '1' + name: clang (libFuzzer) + run: sudo apt-get update && sudo apt-get install -y clang + + - if: steps.gate.outputs.run == '1' + uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2.87.27 + with: + tool: cargo-fuzz@0.13.2 + + - if: steps.gate.outputs.run == '1' + name: UBSan ${{ matrix.bin }} + env: + FUZZ_SANITIZER: undefined + FUZZ_MAX_TOTAL_TIME: "120" + run: ./scripts/fuzz-run.sh ${{ matrix.bin }} diff --git a/COMPAT.md b/COMPAT.md index fdd431621b..632640d869 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -65,10 +65,10 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Historical blocks | Reconstruct from archive; tip via body queue + peer wire | `blocks/` blk*.dat | | Transport | **BIP324 v2 only** | v1 + v2 | | Mempool structure | Cluster graph + chunks | Cluster mempool (same lineage) | -| Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400) | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | +| Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23` | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation including `3fff::/20`, benchmarking (`198.18.0.0/15` and `2001:2::/48`), class E, and the matching IPv6 ranges). 6to4, well-known NAT64 `64:ff9b::/96`, local NAT64 `64:ff9b:1::/48`, IPv4-compatible `::/96`, and SIIT `::ffff:0:0:0/96` stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo `2001::/32` is dropped. Addr gossip also drops port 0 except I2P (advertised with port 0), Tor v2, and CJDNS addresses outside `fc00::/8`. Unknown BIP155 network ids are still relayed. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/SECURITY.md b/SECURITY.md index f3ce23aa11..c077fe1c8a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,7 +53,11 @@ that affect consensus, P2P attack surface, or Electrum/query integrity. - **P2P:** BIP324 v2-only. DoS parity with Bitcoin Core is **not** claimed, but mitigations are intentional operator surface: max inbound sessions (`--max-inbound`, default 125), per-session message/byte - rate windows, misbehavior score disconnect. + rate windows, misbehavior score disconnect. Peer AEAD is the + `chacha20-poly1305` 0.1 that `bip324` 0.11 links. That is current + `rust-bitcoin/bip324` master, not a local pin. `noise_sv2` already uses + the renamed `chacha20poly1305` crate. No version bump until upstream + publishes one. - **Electrum and Esplora:** plain TCP/HTTP; TLS is an operator reverse-proxy concern. Wallet clients and electrs HTTP (except address-prefix) ([`COMPAT.md`](./COMPAT.md)). diff --git a/TESTING.md b/TESTING.md index f85174f42b..3b4844bd20 100644 --- a/TESTING.md +++ b/TESTING.md @@ -429,7 +429,23 @@ New features: add a high-level scenario; remove obsolete lower-level tests in th ## Core differential Nightly (not a required PR check) `fuzz.yml` runs **21** cargo-fuzz jobs. +That nightly run is the fuzz signal on purpose. Pull requests do not run +a short fuzz job: a 30-second smoke would not catch what the nightly +differential run is for, and it is not a required check. `fuzz/` is not a default workspace member. +Silent-payment tweaks are not a missing fuzz bin. They are libsecp +`combine_keys` and `mul_tweak` plus a tagged hash, covered by unit tests +and the Electrum spend test. + +Sanitizers, nightly only: AddressSanitizer on the wire parsers +(`block_wire`, `v2_contents`, `addrv2_wire`, `inv_getdata_wire`, +`electrum_json`, `asmap`), and on `store_reorg`, `v2_session`, +`cmpct_differential`, and `script_kernel_differential`. The heavy Core +differentials run with `--sanitizer none`. `block_spend_differential` +uses AddressSanitizer on Sunday and none on other days. `FUZZ_SANITIZER` +overrides that choice; unset keeps it. A Sunday (or `workflow_dispatch`) +job runs `asmap` and `block_wire` for 120s with `--sanitizer undefined`. +Miri stays `rbitcoin-primitives` only. Treat crate-root `pub` that exists only so a fuzz target can call it as the same smell as a test-only export: prefer `pub(crate)` plus an in-crate harness, or the published `rbitcoin-node` / CLI binary, even if that costs diff --git a/changelog.d/rpc-cookie-perms.md b/changelog.d/rpc-cookie-perms.md new file mode 100644 index 0000000000..255397cbc8 --- /dev/null +++ b/changelog.d/rpc-cookie-perms.md @@ -0,0 +1,9 @@ +Changed + +- **`--rpc-cookie-perms owner|group|all`** (conf `rpc_cookie_perms=`, NixOS + `services.rbitcoin.rpc.cookiePerms`). Default `owner` refuses a cookie + with group or world bits at startup. `group` allows a 0640 file shared + with another service and refuses group write. `all` allows world read + (0644) and refuses group or world write. The check is Unix mode bits; + Windows ignores the flag. The node still does not create or chmod the + cookie. A group- or world-readable cookie used to warn once and start. diff --git a/crates/rbitcoin-bench/Cargo.toml b/crates/rbitcoin-bench/Cargo.toml index 70cdd92b56..f0ea9fc97a 100644 --- a/crates/rbitcoin-bench/Cargo.toml +++ b/crates/rbitcoin-bench/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" required-features = ["cli"] [features] +mutants = [] default = [] cli = [] diff --git a/crates/rbitcoin-cli/Cargo.toml b/crates/rbitcoin-cli/Cargo.toml index a77465dcf8..07dc048762 100644 --- a/crates/rbitcoin-cli/Cargo.toml +++ b/crates/rbitcoin-cli/Cargo.toml @@ -11,6 +11,9 @@ description = "rbitcoin RPC CLI client" name = "rbitcoin-cli" path = "src/main.rs" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } mimalloc = { workspace = true } diff --git a/crates/rbitcoin-consensus/Cargo.toml b/crates/rbitcoin-consensus/Cargo.toml index b8c38c8d03..cd85a764a3 100644 --- a/crates/rbitcoin-consensus/Cargo.toml +++ b/crates/rbitcoin-consensus/Cargo.toml @@ -10,6 +10,8 @@ description = "Block and transaction validation / confirmability" [features] # Lets script-oracle call the Core script assembler. Off for the node. corpus = ["dep:serde_json"] +# Nightly cargo-mutants only. Off for the node build. +mutants = ["dep:mutants"] [dependencies] rbitcoin-primitives = { workspace = true } @@ -19,7 +21,7 @@ bitcoin_hashes = { workspace = true } # Corpus rows are JSON. The node build leaves this feature off. serde_json = { workspace = true, optional = true } # Attribute only. cargo-mutants reads it; it does not change compiled code. -mutants = "0.0.4" +mutants = { version = "0.0.4", optional = true } # Parallel script checks use in-crate `script_pool` (std threads) — no rayon. [dev-dependencies] diff --git a/crates/rbitcoin-consensus/src/script/interpreter.rs b/crates/rbitcoin-consensus/src/script/interpreter.rs index 08f366f524..706b9e65bd 100644 --- a/crates/rbitcoin-consensus/src/script/interpreter.rs +++ b/crates/rbitcoin-consensus/src/script/interpreter.rs @@ -932,7 +932,7 @@ pub(crate) fn eval_script( } /// The type bit does not overlap the low 16, so `|` and `^` are the same mask. -#[mutants::skip] +#[cfg_attr(feature = "mutants", mutants::skip)] fn csv_locktime_mask(type_bit: u32) -> u32 { 0x0000_ffff | type_bit } diff --git a/crates/rbitcoin-electrum/Cargo.toml b/crates/rbitcoin-electrum/Cargo.toml index 4a5d785e8a..a7260e6cc0 100644 --- a/crates/rbitcoin-electrum/Cargo.toml +++ b/crates/rbitcoin-electrum/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Electrum protocol server (scripthash JSON-RPC over TCP)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-esplora/Cargo.toml b/crates/rbitcoin-esplora/Cargo.toml index a90971ba16..22341a077b 100644 --- a/crates/rbitcoin-esplora/Cargo.toml +++ b/crates/rbitcoin-esplora/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Esplora-compatible REST (electrs HTTP drop-in except address-prefix)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-log/Cargo.toml b/crates/rbitcoin-log/Cargo.toml index 1e355ac796..583792e1ce 100644 --- a/crates/rbitcoin-log/Cargo.toml +++ b/crates/rbitcoin-log/Cargo.toml @@ -16,6 +16,7 @@ workspace = true # product graph. These targets exist so `cargo fmt --all` still formats it; # required-features keeps cargo test / clippy from compiling those files. [features] +mutants = [] fmt-fuzz-anchor = [] [[test]] diff --git a/crates/rbitcoin-mempool/Cargo.toml b/crates/rbitcoin-mempool/Cargo.toml index 957057beff..a930d05abf 100644 --- a/crates/rbitcoin-mempool/Cargo.toml +++ b/crates/rbitcoin-mempool/Cargo.toml @@ -15,6 +15,7 @@ rbitcoin-store = { workspace = true } bitcoin = { workspace = true } [features] +mutants = [] # Net tests pin a full slot table. A production build leaves this off. test-hooks = [] diff --git a/crates/rbitcoin-net/Cargo.toml b/crates/rbitcoin-net/Cargo.toml index 86d35fefeb..7b8d356e72 100644 --- a/crates/rbitcoin-net/Cargo.toml +++ b/crates/rbitcoin-net/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Bitcoin P2P (BIP324, IBD, tip follow, compact v2, tx relay)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 4e9fc8b727..d765078bc1 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -23,7 +23,7 @@ use rbitcoin_query::{ confirm_wire_load_phase_pipelined, confirm_write_phase, header_to_record, validate_header, PlanStampOutcome, ScriptOkBatch, ScriptPreverified, WireLoadPipeline, }; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; thread_local! { @@ -170,7 +170,42 @@ impl HeldBodies { } } +/// Dial and reconsider hints. Not a validity set: the oldest drops at the cap. +const FORK_TIP_CAP: usize = 64; + +struct ForkTips { + tips: VecDeque, +} + +impl ForkTips { + fn new() -> Self { + Self { + tips: VecDeque::new(), + } + } + + fn insert(&mut self, hash: BlockHash) { + if self.tips.contains(&hash) { + return; + } + if self.tips.len() == FORK_TIP_CAP { + self.tips.pop_front(); + } + self.tips.push_back(hash); + } + + fn remove(&mut self, hash: &BlockHash) { + self.tips.retain(|h| h != hash); + } + + fn iter(&self) -> impl Iterator + '_ { + self.tips.iter().copied() + } +} + /// Operator-invalidated hashes and best-chain paths (hashes only, height order). +/// +/// Uncapped. Forgetting an invalid hash would make that block acceptable again. struct Invalidated { set: RwLock>, paths: RwLock>>, @@ -369,7 +404,7 @@ pub struct ChainHub { held_bodies: RwLock, precious: RwLock>, /// Losing tips after a most-work reorg (hashes only). Bodies via archive. - fork_tips: RwLock>, + fork_tips: RwLock, /// Header-only tips (`submitheader` / P2P headers): hash → (prev, height). header_tips: RwLock, /// Set around `accept_branch` connect so each `TipEvent` carries branch length. @@ -496,7 +531,7 @@ impl ChainHub { invalidated: Invalidated::new(), held_bodies: RwLock::new(HeldBodies::new()), precious: RwLock::new(None), - fork_tips: RwLock::new(HashSet::new()), + fork_tips: RwLock::new(ForkTips::new()), header_tips: RwLock::new(HeaderTips::new()), announce_reorg_len: AtomicU32::new(0), minimum_chain_work: RwLock::new(None), @@ -1007,7 +1042,7 @@ impl ChainHub { } }; - for h in self.fork_tips.read().unwrap().iter().copied() { + for h in self.fork_tips.read().unwrap().iter() { record(&mut out, h, "valid-fork"); } // The header and held walks below re-read `header_tips` and @@ -2086,7 +2121,7 @@ impl ChainHub { fn try_apply_after_invalidate(&self) -> Result, NetError> { let inv = self.invalidated.set.read().unwrap().clone(); let precious = *self.precious.read().unwrap(); - let mut starts: Vec = self.fork_tips.read().unwrap().iter().copied().collect(); + let mut starts: Vec = self.fork_tips.read().unwrap().iter().collect(); starts.extend(self.held_bodies.read().unwrap().keys()); if let Some(p) = precious { if !starts.contains(&p) { @@ -3936,6 +3971,34 @@ pub(crate) fn sixty_four_byte_body(prev: BlockHash, time: u32) -> Block { #[cfg(test)] mod tests { use super::*; + + #[test] + fn fork_tips_drop_the_oldest_at_the_cap() { + let mut tips = ForkTips::new(); + let hashes: Vec = (0..65u8) + .map(|i| BlockHash::from_byte_array([i; 32])) + .collect(); + for hash in &hashes { + tips.insert(*hash); + } + let kept: Vec = tips.iter().collect(); + assert_eq!(kept.len(), FORK_TIP_CAP); + assert!(!kept.contains(&hashes[0])); + assert_eq!(kept[0], hashes[1]); + assert_eq!(*kept.last().unwrap(), hashes[64]); + tips.remove(&hashes[1]); + assert!(!tips.iter().any(|h| h == hashes[1])); + let before = tips.iter().count(); + tips.insert(hashes[64]); + assert_eq!( + tips.iter().count(), + before, + "a hash already kept is not moved" + ); + tips.insert(hashes[1]); + assert_eq!(tips.iter().count(), before + 1); + assert_eq!(tips.iter().last(), Some(hashes[1])); + } use crate::most_work::sum_work; use bitcoin::absolute::LockTime; use bitcoin::block::Version; diff --git a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs index 4035c741cf..844755eeb5 100644 --- a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs +++ b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs @@ -742,17 +742,26 @@ fn apply_peer_event_body_and_control_surface() { // Addrs + inject filter. inject_learned_addrs(&mut book, &[], local, 1); + let routable = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); inject_learned_addrs( &mut book, &[ crate::NetAddr::Ip(addr(2)), + crate::NetAddr::Ip(routable), crate::NetAddr::Ip(local), crate::NetAddr::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 1)), ], local, 1, ); - assert!(book.entry(&addr(2)).is_some()); + assert!( + book.entry(&addr(2)).is_none(), + "gossip drops RFC1918; addpeeraddress still uses add_learned" + ); + assert!( + book.entry(&routable).is_some(), + "gossip keeps a public address" + ); let onion: crate::NetAddr = "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" .parse() diff --git a/crates/rbitcoin-net/src/ibd/events/mod.rs b/crates/rbitcoin-net/src/ibd/events/mod.rs index d2a6d85874..d1eedeab4b 100644 --- a/crates/rbitcoin-net/src/ibd/events/mod.rs +++ b/crates/rbitcoin-net/src/ibd/events/mod.rs @@ -935,11 +935,11 @@ pub(crate) fn inject_learned_addrs( } let mut added = 0usize; for &a in addrs { - if a.port() == 0 { + if a.port() == 0 || crate::netaddr::netaddr_unroutable_gossip(a) { continue; } match a { - crate::NetAddr::Ip(s) if s == local_addr || s.ip().is_unspecified() => continue, + crate::NetAddr::Ip(s) if s == local_addr => continue, _ => {} } if book.add_learned_addr(a, MAX_PEER_POOL) { diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index a4a5508021..b92e9ff040 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -4,7 +4,7 @@ use crate::error::NetError; use bitcoin::p2p::address::{AddrV2, AddrV2Message}; use sha3::{Digest, Sha3_256}; use std::fmt; -use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::str::FromStr; const B32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; @@ -50,6 +50,164 @@ pub(crate) fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { only.is_empty() || only.iter().any(|n| n.matches_addr(addr)) } +/// Prefixes `Ipv6Addr` does not classify on 1.95. +/// +/// `2001:db8::/32` and `3fff::/20` documentation, `2001:2::/48` benchmarking, +/// `2001:10::/28` ORCHID, `2001:20::/28` ORCHIDv2, and `100::/64` discard. +/// `is_documentation` is still unstable. +fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { + let o = v.octets(); + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x0d && o[3] == 0xb8 { + return true; + } + // RFC 9637 documentation. Core `IsRFC9637` rejects this in `IsValid`. + if o[0] == 0x3f && o[1] == 0xff && (o[2] & 0xf0) == 0 { + return true; + } + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && (o[3] & 0xf0) == 0x10 { + return true; + } + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && (o[3] & 0xf0) == 0x20 { + return true; + } + // RFC 5180 benchmarking. The IPv4 twin is `198.18.0.0/15`. The last + // 80 bits are free, so the whole prefix has to go. + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && o[3] == 0x02 && o[4] == 0x00 && o[5] == 0x00 + { + return true; + } + o[0] == 0x01 && o[1] == 0x00 && o[2..8].iter().all(|&b| b == 0) +} + +/// Tunnel and translation prefixes. +/// +/// A public embedded IPv4 stays only in its canonical form: 6to4 and local +/// NAT64 with the non-IPv4 bits zero, well-known NAT64, IPv4-compatible +/// `::/96`, and SIIT `::ffff:0:0:0/96`. Any other suffix is a distinct map +/// key and would fill the flat 8192 book. Teredo `2001::/32` is dropped +/// entirely: the server address is 32 free bits, so a canonical suffix +/// still fills the book. IPv4-mapped `::ffff:0:0/96` is unwrapped earlier. +fn ipv6_embedded_bogon(v: Ipv6Addr) -> bool { + let o = v.octets(); + // Teredo. Client IPv4 is bit-flipped; server, flags, and port are free. + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && o[3] == 0x00 { + return true; + } + // 6to4. Bytes 2–5 are the IPv4. Bytes 6–15 must be zero. + if o[0] == 0x20 && o[1] == 0x02 { + return ipv4_unroutable(Ipv4Addr::new(o[2], o[3], o[4], o[5])) + || o[6..].iter().any(|&b| b != 0); + } + // Well-known NAT64 `64:ff9b::/96`. + if o[0] == 0x00 + && o[1] == 0x64 + && o[2] == 0xff + && o[3] == 0x9b + && o[4..12].iter().all(|&b| b == 0) + { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + // Local NAT64 `64:ff9b:1::/48`. Bytes 6–11 must be zero. + if o[0] == 0x00 && o[1] == 0x64 && o[2] == 0xff && o[3] == 0x9b && o[4] == 0x00 && o[5] == 0x01 + { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])) + || o[6..12].iter().any(|&b| b != 0); + } + // SIIT `::ffff:0:0:0/96`. Not IPv4-mapped `::ffff:0:0/96`. + if o[..8].iter().all(|&b| b == 0) && o[8] == 0xff && o[9] == 0xff && o[10] == 0 && o[11] == 0 { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + // Deprecated IPv4-compatible `::/96`. + if o[..12].iter().all(|&b| b == 0) { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + false +} + +/// `100.64.0.0/10`. `Ipv4Addr::is_shared` is still unstable on 1.95. +fn ipv4_shared(v: Ipv4Addr) -> bool { + let o = v.octets(); + o[0] == 100 && (o[1] & 0xc0) == 64 +} + +/// `198.18.0.0/15`. `Ipv4Addr::is_benchmarking` is still unstable on 1.95. +fn ipv4_benchmarking(v: Ipv4Addr) -> bool { + let o = v.octets(); + o[0] == 198 && (o[1] & 0xfe) == 18 +} + +/// `240.0.0.0/4`. `Ipv4Addr::is_reserved` is still unstable on 1.95. +fn ipv4_reserved(v: Ipv4Addr) -> bool { + v.octets()[0] & 0xf0 == 240 +} + +/// `0.0.0.0/8`. `is_unspecified` is only `0.0.0.0`. +fn ipv4_this_network(v: Ipv4Addr) -> bool { + v.octets()[0] == 0 +} + +fn ipv4_unroutable(v: Ipv4Addr) -> bool { + v.is_unspecified() + || v.is_loopback() + || v.is_private() + || v.is_link_local() + || v.is_broadcast() + || v.is_multicast() + || v.is_documentation() + || ipv4_shared(v) + || ipv4_benchmarking(v) + || ipv4_reserved(v) + || ipv4_this_network(v) +} + +/// Clearnet bogon. CJDNS `fc00::/8` is an overlay, not a unique-local bogon. +pub(crate) fn clearnet_unroutable(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v) => ipv4_unroutable(v), + IpAddr::V6(v) => { + if is_cjdns_ip(v) { + return false; + } + if let Some(v4) = v.to_ipv4_mapped() { + return ipv4_unroutable(v4); + } + v.is_unspecified() + || v.is_loopback() + || v.is_unicast_link_local() + || v.is_unique_local() + || v.is_multicast() + || ipv6_special_unroutable(v) + || ipv6_embedded_bogon(v) + } + } +} + +/// Seed inject, IBD and steady-state addr gossip, getaddr, and peers-file +/// load. Onion, I2P, and CJDNS are not IP bogons. `add_learned` stays open +/// for `addpeeraddress` and `--connect`. +pub(crate) fn netaddr_unroutable_gossip(addr: NetAddr) -> bool { + match addr { + NetAddr::Ip(s) => clearnet_unroutable(s.ip()), + NetAddr::Onion { .. } | NetAddr::I2p { .. } | NetAddr::Cjdns { .. } => false, + } +} + +/// Rows the addrv2 relay loop must not forward. +/// +/// Port 0 is not a dialable endpoint except I2P, which Core advertises with +/// port 0. Tor v2 and CJDNS outside `fc00::/8` never become a [`NetAddr`], +/// so the unroutable check would not see them. Unknown BIP155 network ids +/// are still forwarded. +pub(crate) fn addrv2_skip_relay(msg: &AddrV2Message) -> bool { + match &msg.addr { + AddrV2::I2p(_) => false, + AddrV2::TorV2(_) => true, + AddrV2::Cjdns(ip) if !is_cjdns_ip(*ip) => true, + _ if msg.port == 0 => true, + _ => NetAddr::from_addrv2(msg).is_some_and(netaddr_unroutable_gossip), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum NetAddr { Ip(SocketAddr), diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index 58170cdc1e..e9ea942572 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -3062,6 +3062,9 @@ fn on_addrv2( let mut batches: Vec> = vec![Vec::new(); neighbors.len()]; for addr in list.iter().take(allow) { + if crate::netaddr::addrv2_skip_relay(addr) { + continue; + } let key = addr_relay_key(addr); let n_dest = if neighbors.len() <= 1 { neighbors.len() diff --git a/crates/rbitcoin-net/src/peer_hostile_journey.rs b/crates/rbitcoin-net/src/peer_hostile_journey.rs index cea7cadb99..4f2042bc4a 100644 --- a/crates/rbitcoin-net/src/peer_hostile_journey.rs +++ b/crates/rbitcoin-net/src/peer_hostile_journey.rs @@ -3,7 +3,7 @@ use bitcoin::p2p::message_blockdata::GetHeadersMessage; use bitcoin::p2p::message_network::VersionMessage; use bitcoin::p2p::ServiceFlags; use bitcoin::ScriptBuf; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; fn hostile_ver(addr: SocketAddr) -> VersionMessage { VersionMessage { @@ -19,6 +19,131 @@ fn hostile_ver(addr: SocketAddr) -> VersionMessage { } } +#[tokio::test] +async fn addrv2_gossip_drops_unroutable_clearnet() { + use bitcoin::p2p::message::NetworkMessage; + use std::sync::{Arc, Mutex}; + + let peers = crate::peers::PeerHub::new(); + let book = Arc::new(Mutex::new(crate::seeds::AddrMan::new())); + peers.set_addrman(Arc::clone(&book)); + let src_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let ver = hostile_ver(src_addr); + let peer = peers.register( + src_addr, + src_addr, + &ver, + true, + crate::peers::PeerConnType::Inbound, + ); + peer.set_wants_addrv2(); + let dst_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8)), 8333); + let dst = peers.register( + dst_addr, + dst_addr, + &ver, + true, + crate::peers::PeerConnType::Inbound, + ); + dst.set_wants_addrv2(); + let (_dst_tx, mut dst_rx) = mpsc::unbounded_channel(); + dst.attach_out(_dst_tx); + peers.set_mock_now(peer.connected_at_secs() + 10_000); + + let public = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(9, 9, 9, 9)), + port: 8333, + }; + let lan = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(10, 0, 0, 1)), + port: 8333, + }; + let cgnat = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(100, 64, 0, 1)), + port: 8333, + }; + let zero_port = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(9, 9, 9, 8)), + port: 0, + }; + let i2p = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::I2p([0x11; 32]), + port: 0, + }; + let tor_v2 = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::TorV2([0xab; 10]), + port: 8333, + }; + let cjdns_bad = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + port: 8333, + }; + let cjdns = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)), + port: 8333, + }; + let unknown = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Unknown(0x99, vec![1, 2, 3, 4]), + port: 8333, + }; + let mut follow = PeerFollowState::new(); + on_addrv2( + &mut follow, + Some(peer.as_ref()), + &[ + lan, + public.clone(), + cgnat, + zero_port, + i2p.clone(), + tor_v2, + cjdns_bad, + cjdns.clone(), + unknown.clone(), + ], + ) + .unwrap(); + + let got = dst_rx + .try_recv() + .expect("the public address is relayed") + .expect_msg(); + let NetworkMessage::AddrV2(sent) = got else { + panic!("expected addrv2, got {got:?}"); + }; + assert_eq!(sent, vec![public, i2p, cjdns, unknown]); + assert!( + dst_rx.try_recv().is_err(), + "unroutable rows, port-0 clearnet, Tor v2, and non-CJDNS are not relayed" + ); + let book = book.lock().unwrap_or_else(|e| e.into_inner()); + let public_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 8333); + let lan_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8333); + assert!(book.entry(&public_sock).is_some()); + assert!( + book.entry(&lan_sock).is_none(), + "learn_addrv2 does not store RFC1918" + ); +} + fn hostile_addr(port: u16) -> AddrV2Message { AddrV2Message { time: 1_700_000_000, diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index b7636defd7..aa5379cc3d 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -1744,8 +1744,8 @@ impl PeerHub { /// Learn IPv4/IPv6/Tor v3/I2P/CJDNS rows from BIP155 `addrv2`. /// - /// Rows for a net this process cannot dial are not stored. Relaying the - /// message is separate and does not consult the book. + /// Rows for a net this process cannot dial are not stored. Unroutable + /// clearnet is not stored. Relaying is separate and drops the same rows. pub fn learn_addrv2(&self, list: &[bitcoin::p2p::address::AddrV2Message]) { let g = self.addrman.lock().unwrap_or_else(|e| e.into_inner()); let Some(am) = g.as_ref() else { @@ -1754,6 +1754,9 @@ impl PeerHub { let mut book = am.lock().unwrap_or_else(|e| e.into_inner()); for a in list { if let Some(addr) = crate::NetAddr::from_addrv2(a) { + if crate::netaddr::netaddr_unroutable_gossip(addr) { + continue; + } book.add_learned_addr(addr, crate::seeds::MAX_ADDR_MAN); } } @@ -1794,6 +1797,7 @@ impl PeerHub { .iter() .map(|e| e.addr) .filter(|a| v2 || matches!(a, crate::NetAddr::Ip(_))) + .filter(|a| !crate::netaddr::netaddr_unroutable_gossip(*a)) .collect(); let n = addrs.len(); let pct_cap = (n * crate::peer::MAX_PCT_ADDR_TO_SEND / 100).max(1); @@ -4002,5 +4006,21 @@ mod tests { assert_ne!(a, expired); } + #[test] + fn getaddr_omits_unroutable_clearnet() { + let hub = PeerHub::new(); + hub.set_mock_now(1_700_000_000); + let mut am = crate::seeds::AddrMan::new(); + for addr in ["10.0.0.1:8333", "100.64.0.1:8333", "127.0.0.1:8333"] { + assert!(am.add_learned(addr.parse().unwrap(), 64)); + } + hub.set_addrman(Arc::new(Mutex::new(am))); + let got = hub.addr_response_for_bind(SocketAddr::from(([127, 0, 0, 1], 18444))); + assert!( + got.is_empty(), + "getaddr must not advertise unroutable clearnet: {got:?}" + ); + } + include!("overlay_addrman_journey.rs"); } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 5101df767b..e818096c34 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -333,6 +333,9 @@ impl AddrMan { pub fn inject(&mut self, addrs: impl IntoIterator) { for a in addrs { + if crate::netaddr::clearnet_unroutable(a.ip()) { + continue; + } self.add(a); } self.sort_order_ipv4_first(); @@ -961,6 +964,9 @@ impl AddrMan { ) })? }; + if crate::netaddr::netaddr_unroutable_gossip(addr) { + continue; + } am.remember(addr, PeerFlags(flags_u)); } if !saw_magic && am.is_empty() { @@ -1004,10 +1010,91 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, o)), 8333) } + #[test] + fn inject_drops_unroutable_and_add_learned_keeps_it() { + use std::net::Ipv6Addr; + let mut book = AddrMan::new(); + let public = addr(4); + book.inject([ + "10.0.0.1:8333".parse().unwrap(), + "127.0.0.1:8333".parse().unwrap(), + "192.0.2.1:8333".parse().unwrap(), + "0.0.0.0:8333".parse().unwrap(), + "100.64.0.1:8333".parse().unwrap(), + "198.18.0.1:8333".parse().unwrap(), + "240.0.0.1:8333".parse().unwrap(), + "[2001:db8::1]:8333".parse().unwrap(), + "[2001:10::1]:8333".parse().unwrap(), + "[2001:20::1]:8333".parse().unwrap(), + "[100::1]:8333".parse().unwrap(), + "0.1.2.3:8333".parse().unwrap(), + "[2002:0a00:1::]:8333".parse().unwrap(), + "[2002:0102:0304::1]:8333".parse().unwrap(), + "[64:ff9b::a00:1]:8333".parse().unwrap(), + "[::a00:1]:8333".parse().unwrap(), + "[::ffff:0:a00:1]:8333".parse().unwrap(), + "[::ffff:a00:1]:8333".parse().unwrap(), + "[64:ff9b:1::a00:1]:8333".parse().unwrap(), + "[64:ff9b:1:1::102:304]:8333".parse().unwrap(), + "[2001:0:102:304::f5ff:fffe]:8333".parse().unwrap(), + "[3fff::1]:8333".parse().unwrap(), + "[2001:2::1]:8333".parse().unwrap(), + "[2001:2:0:ffff:ffff:ffff:ffff:ffff]:8333".parse().unwrap(), + public, + ]); + assert_eq!(book.len(), 1); + assert!(book.entry(&public).is_some()); + let sixto4_public: SocketAddr = "[2002:0102:0304::]:8333".parse().unwrap(); + let nat64_public: SocketAddr = "[64:ff9b::102:304]:8333".parse().unwrap(); + let compatible_public: SocketAddr = "[::102:304]:8333".parse().unwrap(); + let siit_public: SocketAddr = "[::ffff:0:102:304]:8333".parse().unwrap(); + let nat64_local_public: SocketAddr = "[64:ff9b:1::102:304]:8333".parse().unwrap(); + let mapped_public: SocketAddr = "[::ffff:102:304]:8333".parse().unwrap(); + let bench_outside: SocketAddr = "[2001:2:1::1]:8333".parse().unwrap(); + book.inject([ + sixto4_public, + nat64_public, + compatible_public, + siit_public, + nat64_local_public, + mapped_public, + bench_outside, + ]); + assert!( + book.entry(&sixto4_public).is_some(), + "6to4 of a public IPv4 stays when the suffix is zero" + ); + assert!( + book.entry(&nat64_public).is_some(), + "NAT64 of a public IPv4 stays" + ); + assert!(book.entry(&compatible_public).is_some()); + assert!(book.entry(&siit_public).is_some()); + assert!(book.entry(&nat64_local_public).is_some()); + assert!(book.entry(&mapped_public).is_some()); + assert!( + book.entry(&bench_outside).is_some(), + "2001:2:1::/48 is outside the benchmarking prefix" + ); + let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); + assert!(book.add_learned(lan, 64)); + assert!(book.entry(&lan).is_some()); + let cjdns: SocketAddr = + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)), 8333); + book.set_cjdns_reachable(true); + book.inject([cjdns]); + assert!( + book.entries() + .iter() + .any(|e| matches!(e.addr, NetAddr::Cjdns { .. })), + "fc00::/8 is CJDNS, not a unique-local bogon" + ); + } + fn addr_n(i: u32) -> SocketAddr { SocketAddr::new( IpAddr::V4(Ipv4Addr::new( - 10, + 11, ((i >> 16) & 0xff) as u8, ((i >> 8) & 0xff) as u8, (i & 0xff) as u8, @@ -1713,6 +1800,53 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn load_drops_unroutable_clearnet_and_keeps_cjdns() { + let dir = std::env::temp_dir().join(format!( + "rbitcoin-peers-bogon-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("peers"); + std::fs::write( + &path, + "\ +rbitcoin-peers-v2 +1.2.3.4:8333 0x01 +10.0.0.1:8333 0x01 +100.64.0.1:8333 0x00 +[fc00::1]:8333 0x00 +", + ) + .unwrap(); + let loaded = AddrMan::load(&path).unwrap(); + let public: SocketAddr = "1.2.3.4:8333".parse().unwrap(); + assert!( + loaded.entry(&public).is_some(), + "a public address still loads" + ); + assert!(loaded.flags(&public).has_connected()); + let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); + assert!( + loaded.entry(&lan).is_none(), + "a remembered RFC1918 address is not dialed again" + ); + let cgnat: SocketAddr = "100.64.0.1:8333".parse().unwrap(); + assert!(loaded.entry(&cgnat).is_none()); + assert!( + loaded + .entries() + .iter() + .any(|e| matches!(e.addr, NetAddr::Cjdns { .. })), + "fc00::/8 stays a CJDNS row" + ); + let _ = std::fs::remove_dir_all(&dir); + } + fn onion() -> NetAddr { "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" .parse() diff --git a/crates/rbitcoin-node/Cargo.toml b/crates/rbitcoin-node/Cargo.toml index bedc9dab58..35749196db 100644 --- a/crates/rbitcoin-node/Cargo.toml +++ b/crates/rbitcoin-node/Cargo.toml @@ -11,6 +11,9 @@ description = "rbitcoin full node binary and lifecycle" name = "rbitcoin-node" path = "src/main.rs" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index e2821552fc..caeed6564a 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -312,7 +312,7 @@ fn operator_usage() -> String { [--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] [--health-listen [ADDR]] [--metrics] \\\n\ [--sv2-tp-listen ADDR] [--sv2-tp-authority-sec KEY] [--sv2-tp-authority-sec-file PATH] [--sv2-tp-cert-validity SECS] [--sv2-tp-stale-grace SECS] [--sv2-tp-fee-delta SATS] [--sv2-tp-template-interval SECS] \\\n\ [--sh-index] [--block-filter-index] [--prune-seqsigwit] [--prune-seqsigwit-ram-threshold-bytes N] [--datadir-hdd] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--electrum-max-subs N] [--esplora-block-template] \\\n\ - [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ + [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-cookie-perms owner|group|all] [--rpc-work-queue N] \\\n\ [--milestone HEIGHT] \\\n\ [--max-outbound N] [--max-inbound N] \\\n\ [--mempool-size-mb N] [--mempool-expiry HOURS] \\\n\ @@ -374,7 +374,7 @@ SV2: --sv2-tp-listen ADDR serves the Stratum v2 Template Distribution Protocol ( --sv2-tp-stale-grace SECS keeps a replaced tip's templates answering for SECS, at most 86400 (default 10).\n\ --sv2-tp-fee-delta SATS pushes a same-tip template once its fees gain SATS over the last one sent (default 1000).\n\ --sv2-tp-template-interval SECS checks for that gain every SECS, 1 to 86400 (default 5).\n\ -RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ +RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. --rpc-cookie-perms owner|group|all (default owner) is the widest Unix mode that file may have (owner 0600, group 0640, all 0644); the node never creates or chmods it. Windows has no mode bits. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ Cold files: --datadir-cold PATH puts append-only IBD files under PATH/store\n\ (seqsigwit, txstat, input, and blockfilter / sp_tweaks once enabled).\n\ Default (flag omitted): those files live under --datadir. --prune-seqsigwit\n\ @@ -660,6 +660,7 @@ mod tests { "--rpc-socket", "--rpc-token-file", "--rpc-cookie-file", + "--rpc-cookie-perms", "--proxy", "--onion", "--proxy-randomize", @@ -1194,6 +1195,22 @@ mod tests { std::fs::write(&cookie, "__cookie__:secret").unwrap(); // Socket-only RPC would silently ignore the cookie (message pinned in config.rs). assert_exit(run(&["--rpc"]), ExitCode::FAILURE); + assert_exit( + run(&["--rpc-listen", "--rpc-cookie-perms", "0640"]), + ExitCode::from(2), + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_exit(run(&["--rpc-listen"]), ExitCode::FAILURE); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o640)).unwrap(); + assert_exit( + run(&["--rpc-listen", "--rpc-cookie-perms", "group"]), + ExitCode::SUCCESS, + ); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); + } assert_exit(run(&["--rpc-listen"]), ExitCode::SUCCESS); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 0c99da0c43..a90f270938 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -261,6 +261,9 @@ pub struct RpcOpts { pub token_file: Option, /// Opt-in Core-format `username:password` cookie accepted as TCP HTTP Basic. pub cookie_file: Option, + /// Widest Unix mode `--rpc-cookie-perms` allows. Default owner (0600). + /// `group` is at most 0640. `all` is at most 0644. + pub cookie_perms: rbitcoin_rpc::RpcCookiePerms, pub work_queue: Option, /// Core REST on the RPC listener. Off unless `--rest` / `rest=`. pub rest: bool, @@ -275,6 +278,7 @@ impl Default for RpcOpts { socket_path: None, token_file: None, cookie_file: None, + cookie_perms: rbitcoin_rpc::RpcCookiePerms::Owner, work_queue: Some(rbitcoin_rpc::DEFAULT_RPC_WORK_QUEUE), rest: false, } @@ -679,7 +683,7 @@ impl NodeConfig { "--rpc-cookie-file applies to TCP only; add --rpc-listen".into(), )); } - rbitcoin_rpc::read_cookie_file(path) + rbitcoin_rpc::read_cookie_file(path, self.rpc.cookie_perms) .map(drop) .map_err(NodeError::Config) } @@ -1245,6 +1249,10 @@ impl NodeConfig { } self.rpc.cookie_file = Some(PathBuf::from(val)); } + "rpc_cookie_perms" => { + self.rpc.cookie_perms = + rbitcoin_rpc::RpcCookiePerms::parse(val).map_err(NodeError::Config)?; + } "ua_comment" => self.uacomments.push(val.to_string()), "test_activation_height" => { let (name, height) = ChainParams::parse_test_activation_height(val) @@ -2299,9 +2307,40 @@ mod tests { let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("read RPC cookie"), "missing file: {err}"); std::fs::write(&cookie, "__cookie__:secret\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("line ending"), "{err}"); std::fs::write(&cookie, "__cookie__:secret").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms owner"), "{err}"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o640)).unwrap(); + cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::Group; + cfg.validate() + .expect("0640 is within --rpc-cookie-perms group"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o660)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::All; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o666)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms all"), "{err}"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + cfg.validate() + .expect("0644 is within --rpc-cookie-perms all"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); + cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::Owner; + } cfg.validate() .expect("Core-format cookie with --rpc-listen"); cfg.rpc.listen_default = false; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index edcc944e78..c62395eed7 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -170,6 +170,7 @@ async fn start_rpc_listener( network: config.network, token_path: Some(config.rpc_token_path()), cookie_path: config.rpc_cookie_path(), + cookie_perms: config.rpc.cookie_perms, work_queue: config.rpc.work_queue, rest: config.rpc.rest, subversion: Some( @@ -3022,10 +3023,28 @@ mod tests { !target_hit.load(Ordering::SeqCst), "SOCKS dial must not open a direct TCP connection to the target" ); - let book = AddrMan::load(&dir.join("peers")).expect("peers saved"); + let peers_path = dir.join("peers"); + let text = std::fs::read_to_string(&peers_path).expect("peers saved"); + let row = text + .lines() + .find(|line| line.starts_with(&format!("{connect} "))) + .unwrap_or_else(|| { + panic!("IBD must flush the dial failure into the saved peer book\n{text}") + }); + let flags = row + .split_whitespace() + .nth(1) + .and_then(|s| s.strip_prefix("0x")) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + .unwrap_or(0); assert!( - book.flags(&connect).failed_last_connect(), - "IBD must flush the dial failure into the saved peer book" + flags & rbitcoin_net::PeerFlags::FAILED_LAST_CONNECT != 0, + "IBD must flush the dial failure into the saved peer book, row {row}" + ); + let book = AddrMan::load(&peers_path).expect("peers saved"); + assert!( + book.entry(&connect).is_none(), + "loopback is not restored from the peers file" ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/rbitcoin-primitives/Cargo.toml b/crates/rbitcoin-primitives/Cargo.toml index 0b783369af..188f5b480f 100644 --- a/crates/rbitcoin-primitives/Cargo.toml +++ b/crates/rbitcoin-primitives/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Shared types for the rbitcoin node" +[features] +mutants = [] + [dependencies] bitcoin_hashes = { workspace = true } diff --git a/crates/rbitcoin-query/Cargo.toml b/crates/rbitcoin-query/Cargo.toml index 42f15acea8..30c9066441 100644 --- a/crates/rbitcoin-query/Cargo.toml +++ b/crates/rbitcoin-query/Cargo.toml @@ -15,10 +15,12 @@ rbitcoin-log = { workspace = true } bitcoin = { workspace = true } arc-swap = { workspace = true } # Attribute only. cargo-mutants reads it; it does not change compiled code. -mutants = "0.0.4" +mutants = { version = "0.0.4", optional = true } [features] test-hooks = [] +# Nightly cargo-mutants only. Off for the node build. +mutants = ["dep:mutants"] [dev-dependencies] # Height-index tests write a raw confirmed slot. Dependents of this crate diff --git a/crates/rbitcoin-query/src/stamp.rs b/crates/rbitcoin-query/src/stamp.rs index e5ecdc5e9e..c5c57b1599 100644 --- a/crates/rbitcoin-query/src/stamp.rs +++ b/crates/rbitcoin-query/src/stamp.rs @@ -162,7 +162,7 @@ fn stamp_inflight_hits<'a>( } // Capacity is not an observable result: empty maps insert the same keys. -#[mutants::skip] +#[cfg_attr(feature = "mutants", mutants::skip)] fn external_parent_stamp(need_len: usize) -> ExternalParentStamp { ExternalParentStamp { resolved: TxidFkMap::with_capacity_and_hasher(need_len / 2, Default::default()), diff --git a/crates/rbitcoin-rpc/Cargo.toml b/crates/rbitcoin-rpc/Cargo.toml index c3d8a861fd..811ceba3d4 100644 --- a/crates/rbitcoin-rpc/Cargo.toml +++ b/crates/rbitcoin-rpc/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Core-class JSON-RPC HTTP server (subset; not full Core parity)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-rpc/src/auth.rs b/crates/rbitcoin-rpc/src/auth.rs index 5746d2905a..bf63b1dab1 100644 --- a/crates/rbitcoin-rpc/src/auth.rs +++ b/crates/rbitcoin-rpc/src/auth.rs @@ -1,7 +1,7 @@ //! RPC authentication: Bearer token plus optional Core cookie credentials. use std::fs; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; /// Bearer token accepted by the RPC server. @@ -57,6 +57,53 @@ pub fn default_socket_path(datadir: &Path) -> PathBuf { datadir.join("rpc.sock") } +/// Widest mode an operator declared for `--rpc-cookie-file`. +/// +/// The node never creates or chmods the cookie. `owner` is mode 0600. +/// `group` is at most 0640 (group read). `all` is at most 0644 (world read). +/// The check is Unix mode bits. Windows ignores the flag. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RpcCookiePerms { + #[default] + Owner, + Group, + All, +} + +impl RpcCookiePerms { + pub fn parse(raw: &str) -> Result { + match raw { + "owner" => Ok(Self::Owner), + "group" => Ok(Self::Group), + "all" => Ok(Self::All), + _ => Err(format!( + "rpc_cookie_perms must be owner, group, or all (got {raw:?})" + )), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Group => "group", + Self::All => "all", + } + } + + /// Unix permission bits no wider than this declaration. + #[cfg(unix)] + fn allows_unix_mode(self, mode: u32) -> bool { + let mode = mode & 0o777; + match self { + Self::Owner => mode & 0o077 == 0, + // Group write/exec and every world bit. 0640 stays. + Self::Group => mode & 0o037 == 0, + // Group and world write/exec. 0644 stays. + Self::All => mode & 0o033 == 0, + } + } +} + /// Read a Core-format RPC cookie. The configured file is never created or logged. /// /// The file must be exactly `username:password` with no line ending, as Core @@ -64,9 +111,33 @@ pub fn default_socket_path(datadir: &Path) -> PathBuf { /// raw file bytes as Basic credentials, so a trailing newline would make the two /// clients disagree. Refusing it here turns mempool's endless 401 into a startup /// error. -pub fn read_cookie_file(path: &Path) -> Result { - let credentials = - fs::read_to_string(path).map_err(|e| format!("read RPC cookie {}: {e}", path.display()))?; +pub fn read_cookie_file(path: &Path, perms: RpcCookiePerms) -> Result { + // Mode bits exist only on Unix. The argument stays so every caller passes it. + #[cfg(not(unix))] + let _ = perms; + let mut file = + fs::File::open(path).map_err(|e| format!("read RPC cookie {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = file + .metadata() + .map_err(|e| format!("stat RPC cookie {}: {e}", path.display()))? + .permissions() + .mode() + & 0o777; + if !perms.allows_unix_mode(mode) { + return Err(format!( + "RPC cookie {}: mode {mode:o} is wider than --rpc-cookie-perms {} \ + (owner is 0600, group is at most 0640, all is at most 0644)", + path.display(), + perms.as_str() + )); + } + } + let mut credentials = String::new(); + file.read_to_string(&mut credentials) + .map_err(|e| format!("read RPC cookie {}: {e}", path.display()))?; if credentials.ends_with(['\n', '\r']) { return Err(format!( "RPC cookie {}: ends with a line ending; write it without one (Core format, e.g. printf not echo)", @@ -82,21 +153,6 @@ pub fn read_cookie_file(path: &Path) -> Result { path.display() )); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - use std::sync::atomic::{AtomicBool, Ordering}; - static WARNED: AtomicBool = AtomicBool::new(false); - if let Ok(meta) = fs::metadata(path) { - let mode = meta.permissions().mode() & 0o777; - if mode & 0o077 != 0 && !WARNED.swap(true, Ordering::Relaxed) { - rbitcoin_log::warn!( - "rpc: cookie {} mode {mode:o} is group or world accessible", - path.display() - ); - } - } - } Ok(RpcCookie { credentials }) } @@ -304,7 +360,12 @@ mod tests { fs::create_dir_all(&dir).unwrap(); let path = dir.join(".cookie"); fs::write(&path, "mempool:secret").unwrap(); - let cookie = read_cookie_file(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let cookie = read_cookie_file(&path, RpcCookiePerms::Owner).unwrap(); assert!(cookie.matches_credentials("mempool:secret")); assert!(!cookie.matches_credentials("mempool:wrong")); for contents in [ @@ -316,18 +377,78 @@ mod tests { "mempool:secret\nnext", ] { fs::write(&path, contents).unwrap(); - assert!(read_cookie_file(&path).is_err(), "{contents:?}"); + assert!( + read_cookie_file(&path, RpcCookiePerms::Owner).is_err(), + "{contents:?}" + ); } // mempool sends the file verbatim, so a line ending would never match: // refuse it with an error that names the fix. for contents in ["mempool:secret\n", "mempool:secret\r\n", "mempool:secret\r"] { fs::write(&path, contents).unwrap(); - let err = read_cookie_file(&path).err().expect("line ending rejected"); + let err = read_cookie_file(&path, RpcCookiePerms::Owner) + .err() + .expect("line ending rejected"); assert!(err.contains("line ending"), "{contents:?}: {err}"); } let _ = fs::remove_dir_all(&dir); } + #[cfg(unix)] + #[test] + fn cookie_mode_must_match_declared_perms() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp(); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(".cookie"); + fs::write(&path, "mempool:secret").unwrap(); + let chmod = |mode: u32| { + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + }; + chmod(0o644); + let err = read_cookie_file(&path, RpcCookiePerms::Owner) + .err() + .expect("0644 is wider than owner"); + assert!(err.contains("644"), "{err}"); + assert!(err.contains("--rpc-cookie-perms owner"), "{err}"); + chmod(0o600); + assert!(read_cookie_file(&path, RpcCookiePerms::Owner).is_ok()); + chmod(0o640); + assert!(read_cookie_file(&path, RpcCookiePerms::Group).is_ok()); + let err = read_cookie_file(&path, RpcCookiePerms::Owner) + .err() + .expect("0640 is wider than owner"); + assert!(err.contains("--rpc-cookie-perms owner"), "{err}"); + chmod(0o644); + let err = read_cookie_file(&path, RpcCookiePerms::Group) + .err() + .expect("0644 is wider than group"); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + chmod(0o660); + let err = read_cookie_file(&path, RpcCookiePerms::Group) + .err() + .expect("0660 is group-writable"); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + chmod(0o666); + let err = read_cookie_file(&path, RpcCookiePerms::All) + .err() + .expect("0666 is wider than world-read"); + assert!(err.contains("--rpc-cookie-perms all"), "{err}"); + chmod(0o644); + assert!(read_cookie_file(&path, RpcCookiePerms::All).is_ok()); + assert_eq!( + RpcCookiePerms::parse("owner").unwrap(), + RpcCookiePerms::Owner + ); + assert_eq!( + RpcCookiePerms::parse("group").unwrap(), + RpcCookiePerms::Group + ); + assert_eq!(RpcCookiePerms::parse("all").unwrap(), RpcCookiePerms::All); + assert!(RpcCookiePerms::parse("0640").is_err()); + let _ = fs::remove_dir_all(&dir); + } + #[cfg(unix)] #[test] fn token_file_is_created_owner_only() { diff --git a/crates/rbitcoin-rpc/src/lib.rs b/crates/rbitcoin-rpc/src/lib.rs index 7dbe5aa099..2cc1a0e34c 100644 --- a/crates/rbitcoin-rpc/src/lib.rs +++ b/crates/rbitcoin-rpc/src/lib.rs @@ -7,7 +7,9 @@ mod blockstats; mod methods; mod server; -pub use auth::{default_socket_path, default_token_path, read_cookie_file, RpcAuth}; +pub use auth::{ + default_socket_path, default_token_path, read_cookie_file, RpcAuth, RpcCookiePerms, +}; pub use methods::{ difficulty_rpc_f64, gbt_template, submit_received_block, HubRegtest, RpcActive, RpcContext, RpcRegtest, SubmitBlockOutcome, diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index f2d104e3f2..e9fb7fdac8 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -2,6 +2,7 @@ use crate::auth::{ parse_basic_auth, parse_bearer_auth, read_cookie_file, resolve_rpc_auth, RpcAuth, RpcCookie, + RpcCookiePerms, }; use crate::methods::{RpcContext, RpcRegtest}; use axum::body::Bytes; @@ -134,6 +135,8 @@ pub struct RpcConfig { pub token_path: Option, /// Optional Core-format `username:password` file accepted as HTTP Basic on TCP. pub cookie_path: Option, + /// Widest mode `--rpc-cookie-perms` allows for [`Self::cookie_path`]. + pub cookie_perms: RpcCookiePerms, /// `getnetworkinfo.subversion`. Empty → `/rbitcoin:VERSION/`. pub subversion: Option, /// HTTP occupancy cap. `None` and `0` are [`DEFAULT_RPC_WORK_QUEUE`]. @@ -213,7 +216,7 @@ pub async fn run_rpc( let cookie = config .cookie_path .as_deref() - .map(read_cookie_file) + .map(|path| read_cookie_file(path, config.cookie_perms)) .transpose()?; let stop = Arc::new(AtomicBool::new(false)); @@ -946,6 +949,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -984,6 +988,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: true, @@ -1084,6 +1089,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1152,6 +1158,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1234,6 +1241,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1275,6 +1283,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1342,6 +1351,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1475,6 +1485,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1539,6 +1550,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1602,6 +1614,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1671,6 +1684,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: Some(1), rest: false, @@ -1728,6 +1742,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: Some(1), rest: false, @@ -1812,6 +1827,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1983,6 +1999,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -2024,6 +2041,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: Some(cookie_path.clone()), + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -2053,7 +2071,13 @@ mod tests { assert!(err.contains("TCP only"), "{err}"); // mempool would send the newline as part of the password: refuse to start. + // Mode is owner-only so the line-ending error is the one under test. std::fs::write(&cookie_path, "mempool:secret\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let err = match run_rpc( with_cookie(), Arc::clone(&query), @@ -2071,6 +2095,11 @@ mod tests { assert!(err.contains("line ending"), "{err}"); std::fs::write(&cookie_path, "mempool:secret").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let handle = run_rpc(with_cookie(), query, None, None, None, None, None) .await .unwrap(); @@ -2173,6 +2202,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -2201,6 +2231,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, diff --git a/crates/rbitcoin-store/Cargo.toml b/crates/rbitcoin-store/Cargo.toml index 25ddec1df7..bebfdb2263 100644 --- a/crates/rbitcoin-store/Cargo.toml +++ b/crates/rbitcoin-store/Cargo.toml @@ -18,6 +18,7 @@ libc = "0.2" getrandom = "0.4" [features] +mutants = [] # Downstream tests call hooks a production build does not compile: # spend-checkpoint interleaving, and a raw confirmed slot. # A crate's dev-dependency turns this on. A production build leaves it off. diff --git a/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml index 3127751c00..1327dd4e0e 100644 --- a/crates/rbitcoin-sv2/Cargo.toml +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Stratum v2 Template Distribution Protocol server (Noise over TCP)" +[features] +mutants = [] + [dependencies] rbitcoin-log = { workspace = true } rbitcoin-consensus = { workspace = true } diff --git a/crates/rbitcoin-test/Cargo.toml b/crates/rbitcoin-test/Cargo.toml index 69f44dc173..30314ce946 100644 --- a/crates/rbitcoin-test/Cargo.toml +++ b/crates/rbitcoin-test/Cargo.toml @@ -60,6 +60,7 @@ name = "asmap_journey" path = "tests/asmap_journey.rs" [features] +mutants = [] # Live private-mesh journeys. Default `cargo test` stays fake SOCKS/SAM/control. overlay = [] diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index d5f3660eda..8abd4424fd 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -2580,8 +2580,23 @@ async fn node_listen_and_exit() { assert!(mempool.exists(), "run_p2p opens the mempool"); let book = rbitcoin_net::AddrMan::load(&peers).expect("junk book replaced"); assert!( - book.flags(&dead).failed_last_connect(), - "the refused --connect is in the saved book" + book.entry(&dead).is_none(), + "loopback is not restored from the peers file" + ); + let text = std::fs::read_to_string(&peers).expect("peers saved"); + let row = text + .lines() + .find(|line| line.starts_with(&format!("{dead} "))) + .unwrap_or_else(|| panic!("the refused --connect is in the saved book\n{text}")); + let flags = row + .split_whitespace() + .nth(1) + .and_then(|s| s.strip_prefix("0x")) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + .unwrap_or(0); + assert!( + flags & rbitcoin_net::PeerFlags::FAILED_LAST_CONNECT != 0, + "the refused --connect is in the saved book, row {row}" ); // The asmap is in place now, the saved book loads, and the wallet servers diff --git a/deny.toml b/deny.toml index 145a523683..78b875e339 100644 --- a/deny.toml +++ b/deny.toml @@ -29,9 +29,29 @@ allow = [ ] [bans] -multiple-versions = "warn" +multiple-versions = "deny" +# `workspace = true` is this lint. The tree has no `version = "*"`. wildcards = "allow" deny = [] +skip = [ + # noise_sv2 aes/chacha still use cpufeatures 0.2. sha3 0.12 uses 0.3. + { name = "cpufeatures", version = "=0.2.17" }, + # Same split: noise_sv2 aead/cipher vs digest 0.11 (sha3). + { name = "crypto-common", version = "=0.1.7" }, + # noise_sv2 2.0.0 still pins secp256k1 0.28. bitcoin and bip324 use 0.29. + { name = "secp256k1", version = "=0.28.2" }, + # Follows the noise_sv2 secp256k1 0.28 pin (sys 0.9). bitcoin uses 0.10. + { name = "secp256k1-sys", version = "=0.9.2" }, + # secp256k1 0.28 pulls hashes 0.13. bitcoin is 0.14. bip324 is 0.16. + { name = "bitcoin_hashes", version = "=0.13.1" }, + { name = "bitcoin_hashes", version = "=0.14.101" }, + # Each hashes line above carries its own hex-conservative. 1.x stays checked. + { name = "hex-conservative", version = "=0.1.2" }, + { name = "hex-conservative", version = "=0.2.3" }, + { name = "hex-conservative", version = "=0.3.2" }, + # rand 0.8 (via noise_sv2) still needs getrandom 0.2. Direct deps are 0.4. + { name = "getrandom", version = "=0.2.17" }, +] [sources] unknown-registry = "deny" diff --git a/docs/ibd-memory.md b/docs/ibd-memory.md index e1fa7b9728..32f05bb43e 100644 --- a/docs/ibd-memory.md +++ b/docs/ibd-memory.md @@ -1,5 +1,10 @@ # IBD memory: intentional caches vs process leaks +`Invalidated` is not capped. Each entry is a validated header or an operator +`invalidateblock`. Dropping one would make that block acceptable again. +`fork_tips` keeps 64 insertion-ordered hashes and drops the oldest. Those +are dial and reconsider hints, not a validity set. + This document is the developer/AI contract for **process-owned** memory on the IBD path. It is **not** about kernel page cache under FdOnly store tables (those count in RSS when faulted but are not Rust heap leaks). Sealed `.fuse8` diff --git a/docs/operator/interfaces.md b/docs/operator/interfaces.md index 5997624477..e814c34d06 100644 --- a/docs/operator/interfaces.md +++ b/docs/operator/interfaces.md @@ -249,6 +249,7 @@ printf '__cookie__:%s' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" | --sh-index \ --rpc-listen 127.0.0.1:8332 \ --rpc-cookie-file /run/rbitcoin/rpc.cookie \ + --rpc-cookie-perms group \ --esplora-listen /run/rbitcoin/esplora.sock \ --log-level info ``` @@ -366,8 +367,11 @@ listener. The node reads that file and never creates it, so write it before each start (the [mempool.space](#mempoolspace) command above does). Keep it readable only by the rbitcoin and mempool service identities. -1. Start the node with `--rpc-listen 127.0.0.1:` and - `--rpc-cookie-file /run/rbitcoin/rpc.cookie`. +1. Start the node with `--rpc-listen 127.0.0.1:`, + `--rpc-cookie-file /run/rbitcoin/rpc.cookie`, and + `--rpc-cookie-perms group` (the 0640 file above). Default `owner` + refuses that mode. NixOS `services.rbitcoin.rpc.cookiePerms = "group"` + passes the flag; leave it unset to keep the unit argv on the node default. 2. Point mempool at the same file: ```json diff --git a/docs/operator/operations.md b/docs/operator/operations.md index f791b03454..e7160fa9e2 100644 --- a/docs/operator/operations.md +++ b/docs/operator/operations.md @@ -110,6 +110,7 @@ Clean smoke: | `--rest` | `rest=` | **off** — unauthenticated `/rest/` on the RPC listener. Own queue. Needs `--rpc` or `--rpc-listen` | | `--rpc-token-file PATH` | `rpc_token_file=` | `{datadir}/rpc.token` (CSPRNG hex; TCP Bearer) | | `--rpc-cookie-file PATH` | `rpc_cookie_file=` | disabled — existing Core `username:password` cookie (no trailing newline) enables TCP HTTP Basic alongside Bearer | +| `--rpc-cookie-perms owner\|group\|all` | `rpc_cookie_perms=` | **owner** — Unix mode ceiling. `group` is at most 0640. `all` is at most 0644. Windows ignores the flag. The node does not chmod the file. NixOS `services.rbitcoin.rpc.cookiePerms` | | `--rpc-work-queue N` | `rpc_work_queue=` | **16** in-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (array batches still run). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | | `--min-relay-tx-fee BTC` | `min_relay_tx_fee=` | unset — Libre default 100 sat/kvB; `0` = no floor; garbage/negatives fail start | | `--mempool-expiry HOURS` | `mempool_expiry=` | unset — hub default; min 1 | diff --git a/docs/rpc.md b/docs/rpc.md index c89fbf69b6..860307aa91 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -52,6 +52,7 @@ later Core RPC is present, including descriptor-wallet calls from 0.21 | `--rpc-listen [ADDR]` / conf `rpc_listen=` | **off** | TCP JSON-RPC; omit ADDR → `127.0.0.1` and Core-matching port (8332 / 18332 / 38332 / 18443). Implies `--rpc`. | | `--rpc-token-file PATH` | `{datadir}/rpc.token` | CSPRNG hex token; TCP `Authorization: Bearer` | | `--rpc-cookie-file PATH` / conf `rpc_cookie_file=` | **off** | Existing Core-format `username:password` file (no trailing newline) accepted as TCP HTTP Basic in addition to Bearer; it is never created or logged | +| `--rpc-cookie-perms owner\|group\|all` / conf `rpc_cookie_perms=` | **owner** | Widest Unix mode the cookie file may have. `owner` is 0600. `group` is at most 0640 (group read; group write refuses startup). `all` is at most 0644. The node never chmods the file. Windows has no mode bits, so the flag is ignored there. NixOS `services.rbitcoin.rpc.cookiePerms` (default unset, so the unit argv omits the flag) | | `--sh-index` | **off** | Class B scripthash for Electrum/Esplora address history. RPC by height/hash/txid does not need it | | `--block-filter-index` | **off** | BIP158 basic. IBD seals them when the flag is on from the start. A later enable still materializes after catch-up. `NODE_COMPACT_FILTERS` is advertised once filters first reach the tip (`getnetworkinfo` lists `COMPACT_FILTERS`), then for the life of the process. `getblockfilter` serves a sealed height, one block whose parent filter is already sealed, and a stale branch back to that sealed fork point. An unsealed best-chain gap is refused. `/rest/blockfilter/` and P2P still serve only heights the watermark covers. Independent of `--sh-index` | | `--rpc-work-queue N` | **16** | In-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (a JSON-RPC array is still one slot). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | diff --git a/docs/wallets.md b/docs/wallets.md index b3825783ca..5d72944e0c 100644 --- a/docs/wallets.md +++ b/docs/wallets.md @@ -70,9 +70,15 @@ printf '__cookie__:%s' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" | --datadir ./datadir-mainnet \ --network mainnet \ --rpc-listen 127.0.0.1:8332 \ - --rpc-cookie-file /run/rbitcoin/rpc.cookie + --rpc-cookie-file /run/rbitcoin/rpc.cookie \ + --rpc-cookie-perms group ``` +`--rpc-cookie-perms` defaults to `owner` (mode 0600). `group` is what makes +the 0640 file above acceptable, and it still refuses group write. `all` is +at most 0644. The check is Unix mode bits; Windows ignores the flag. The +node never chmods the file. NixOS: `services.rbitcoin.rpc.cookiePerms`. + The mempool.space group ownership and unix-socket variant are in [`operator/interfaces.md`](./operator/interfaces.md#mempoolspace-core_rpc). Unix `{datadir}/rpc.sock` (`--rpc`, mode 0600) needs no header. diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 2918359b79..be5958ea73 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -83,6 +83,8 @@ let ++ optional (cfg.rpc.socketPath != null) cfg.rpc.socketPath ++ optional (cfg.rpc.cookieFile != null) "--rpc-cookie-file" ++ optional (cfg.rpc.cookieFile != null) (toString cfg.rpc.cookieFile) + ++ optional (cfg.rpc.cookiePerms != null) "--rpc-cookie-perms" + ++ optional (cfg.rpc.cookiePerms != null) cfg.rpc.cookiePerms ++ optional cfg.electrum.enable "--electrum-listen" ++ optional cfg.electrum.enable (socket cfg.electrum.address cfg.electrum.port) ++ optional (cfg.electrum.enable && cfg.electrum.maxSubs != null) "--electrum-max-subs" @@ -433,6 +435,21 @@ in read it, and mempool must be able to traverse its directory and read the file. The unit's PrivateTmp and ProtectHome hide /tmp and /home, so keep it elsewhere (e.g. /run/rbitcoin). Format: docs/operator/interfaces.md. + A group-readable cookie (mode 0640) also needs `cookiePerms = "group"`. + ''; + }; + + cookiePerms = mkOption { + type = types.nullOr (types.enum [ "owner" "group" "all" ]); + default = null; + example = "group"; + description = '' + Pass `--rpc-cookie-perms`. The node never creates or chmods the cookie. + `null` leaves the node default (`owner`): group or world bits refuse startup. + `group` allows a 0640 file (group read) shared with mempool and refuses + group write. `all` allows world read (0644) and refuses group or world + write. The check is Unix mode bits. Requires `cookieFile`. Omitted from the unit + argv when null, so the default service command does not grow a flag. ''; }; }; @@ -580,6 +597,10 @@ in assertion = cfg.rpc.cookieFile == null || cfg.rpc.enable; message = "services.rbitcoin.rpc.cookieFile requires rpc.enable (the cookie is accepted on TCP only)"; } + { + assertion = cfg.rpc.cookiePerms == null || cfg.rpc.cookieFile != null; + message = "services.rbitcoin.rpc.cookiePerms requires rpc.cookieFile"; + } { assertion = !cfg.metrics || cfg.health.enable; message = "services.rbitcoin.metrics requires health.enable (--metrics needs --health-listen)"; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 2d1179bf77..87225a742b 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -147,6 +147,37 @@ let cookieDataDirMode = cookieInDataDir.config.systemd.tmpfiles.settings."10-rbitcoin"."/var/lib/rbitcoin".d.mode; cookieTcpOnly = "services.rbitcoin.rpc.cookieFile requires rpc.enable (the cookie is accepted on TCP only)"; + cookiePermsNeedFile = "services.rbitcoin.rpc.cookiePerms requires rpc.cookieFile"; + cookieGroup = nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.cookieFile = "/run/rbitcoin/rpc.cookie"; + rpc.cookiePerms = "group"; + }; + } + ]; + }; + cookieGroupExec = cookieGroup.config.systemd.services.rbitcoin.serviceConfig.ExecStart; + cookiePermsAlone = nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.cookiePerms = "group"; + }; + } + ]; + }; metricsNeedHealth = "services.rbitcoin.metrics requires health.enable (--metrics needs --health-listen)"; metricsWithoutHealth = nixpkgs.lib.nixosSystem { inherit (pkgs.stdenv.hostPlatform) system; @@ -227,6 +258,7 @@ assert defaultCfg.p2p.listenOnion == false; assert defaultCfg.rpc.port == 8332; assert defaultCfg.rpc.socketPath == null; assert defaultCfg.rpc.cookieFile == null; +assert defaultCfg.rpc.cookiePerms == null; assert defaultCfg.proxy == null; assert defaultCfg.onionProxy == null; assert defaultCfg.proxyRandomize == true; @@ -291,6 +323,9 @@ assert builtins.match ".*--milestone 100.*" ( ) != null; assert builtins.match ".*--rpc-socket /run/rbitcoin/rpc.sock.*" execStart != null; assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" execStart != null; +assert builtins.match ".*--rpc-cookie-perms.*" execStart == null; +assert builtins.match ".*--rpc-cookie-perms group.*" cookieGroupExec != null; +assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" cookieGroupExec != null; assert builtins.elem "/run/rbitcoin" service.serviceConfig.ReadWritePaths; assert cfg.systemd.tmpfiles.settings."10-rbitcoin"."/run/rbitcoin".d.mode == "0750"; assert builtins.match ".*--electrum-listen 127.0.0.1:50001.*" execStart != null; @@ -348,6 +383,8 @@ assert builtins.match ".*--rpc-cookie-file.*" listenOffExec == null; assert !builtins.elem cookieTcpOnly (failedAssertions system); assert !builtins.elem metricsNeedHealth (failedAssertions system); assert builtins.elem cookieTcpOnly (failedAssertions cookieWithoutTcp); +assert builtins.elem cookiePermsNeedFile (failedAssertions cookiePermsAlone); +assert !builtins.elem cookiePermsNeedFile (failedAssertions cookieGroup); assert builtins.elem metricsNeedHealth (failedAssertions metricsWithoutHealth); assert builtins.elem "127.0.0.1:9332" (builtins.head rbitcoinScrape.static_configs).targets; assert !builtins.elem sv2KeyNotInStore (failedAssertions system); diff --git a/scripts/fuzz-run.sh b/scripts/fuzz-run.sh index 202e722d0d..d4e125f882 100755 --- a/scripts/fuzz-run.sh +++ b/scripts/fuzz-run.sh @@ -160,6 +160,10 @@ elif [[ "$BIN" == "p2p_sequence_differential" || "$BIN" == "chain_review_differe sanitizer="none" timeout=180 fi +# Unset keeps the choice above. A set value overrides it, including `none`. +if [[ -n "${FUZZ_SANITIZER:-}" ]]; then + sanitizer="$FUZZ_SANITIZER" +fi WRAP="$ROOT/scripts/fuzz-rustc-allow-warnings.sh" export RUSTC_WRAPPER="$WRAP" @@ -219,7 +223,7 @@ if [[ "$BIN" == "block_wire" ]]; then merge_seed fuzz/corpus/block_wire \ crates/rbitcoin-consensus/tests/fixtures/signet_block_90719.bin set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" block_wire -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" block_wire -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=1048576 \ @@ -238,7 +242,7 @@ if [[ "$BIN" == "v2_contents" ]]; then merge_seed fuzz/corpus/v2_contents crates/rbitcoin-net/tests/fixtures/v2_sendcmpct.bin merge_seed fuzz/corpus/v2_contents crates/rbitcoin-net/tests/fixtures/v2_getheaders.bin set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" v2_contents -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" v2_contents -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -274,7 +278,7 @@ if [[ "$BIN" == "addrv2_wire" || "$BIN" == "inv_getdata_wire" || "$BIN" == "elec dict_args=(-dict="$dict") fi set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" "$BIN" -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" "$BIN" -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -292,7 +296,7 @@ if [[ "$BIN" == "v2_session" ]]; then merge_seed fuzz/corpus/v2_session crates/rbitcoin-net/tests/fixtures/v2_verack.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-v2.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" v2_session -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" v2_session -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -325,7 +329,7 @@ if [[ "$BIN" == "cmpct_differential" ]]; then fuzz/fixtures/cmpct_fuzz_raw.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-cmpct.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" cmpct_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" cmpct_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -348,7 +352,7 @@ if [[ "$BIN" == "store_reorg" ]]; then crates/rbitcoin-net/tests/fixtures/store_reorg_ops.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-store-reorg.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" store_reorg -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" store_reorg -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=64 \ @@ -386,7 +390,7 @@ if [[ "$BIN" == "script_kernel_differential" ]]; then done log="${TMPDIR:-/tmp}/rbtc-fuzz-script-kernel.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" script_kernel_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" script_kernel_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=2000 \ @@ -410,7 +414,7 @@ if [[ "$BIN" == "chain_review_differential" ]]; then fuzz/fixtures/chain_review_milestone.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-chain-review.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer none chain_review_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" chain_review_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=64 \ @@ -433,7 +437,7 @@ if [[ "$BIN" == "p2p_sequence_differential" ]]; then crates/rbitcoin-net/tests/fixtures/p2p_seq_two_ping.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-p2p-seq.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer none p2p_sequence_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" p2p_sequence_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=256 \ diff --git a/scripts/fuzz-run.test.sh b/scripts/fuzz-run.test.sh index f1fce6b695..097f95a6b5 100755 --- a/scripts/fuzz-run.test.sh +++ b/scripts/fuzz-run.test.sh @@ -187,6 +187,18 @@ assert_ok "asmap dry-run bin" \ grep -qx "FUZZ_BIN=asmap" <<<"$out" assert_ok "asmap dry-run sanitizer address" \ grep -qx "FUZZ_SANITIZER=address" <<<"$out" +over="$(FUZZ_DRY_RUN=1 FUZZ_SANITIZER=undefined "$RUN" asmap)" +assert_ok "FUZZ_SANITIZER overrides asmap" \ + grep -qx "FUZZ_SANITIZER=undefined" <<<"$over" +wire="$(FUZZ_DRY_RUN=1 FUZZ_SANITIZER=undefined "$RUN" block_wire)" +assert_ok "FUZZ_SANITIZER overrides block_wire" \ + grep -qx "FUZZ_SANITIZER=undefined" <<<"$wire" +plain="$(FUZZ_DRY_RUN=1 "$RUN" block_wire)" +assert_ok "block_wire stays address when FUZZ_SANITIZER is unset" \ + grep -qx "FUZZ_SANITIZER=address" <<<"$plain" +heavy="$(FUZZ_DRY_RUN=1 FUZZ_SANITIZER=undefined "$RUN" block_differential)" +assert_ok "FUZZ_SANITIZER overrides an unsanitized differential" \ + grep -qx "FUZZ_SANITIZER=undefined" <<<"$heavy" assert_ok "asmap dry-run no Core" \ grep -qx "FUZZ_NO_CORE=1" <<<"$out" assert_ok "asmap listed in fuzz Cargo.toml" \ @@ -197,6 +209,10 @@ assert_ok "asmap two-prefix-plus-ip seed" \ test -s "$ROOT/crates/rbitcoin-net/tests/fixtures/asmap_two_prefix_plus_ip.bin" assert_ok "asmap listed in fuzz.yml matrix" \ grep -q '{ bin: asmap, core: false }' "$ROOT/.github/workflows/fuzz.yml" +assert_ok "weekly ubsan job names asmap and block_wire" \ + grep -q 'bin: \[asmap, block_wire\]' "$ROOT/.github/workflows/fuzz.yml" +assert_ok "weekly ubsan sets undefined" \ + grep -q 'FUZZ_SANITIZER: undefined' "$ROOT/.github/workflows/fuzz.yml" t6="$(FUZZ_WEEKDAY=6 "$RUN" --default-time)" assert_ok "FUZZ_WEEKDAY=6 is 600" \ diff --git a/scripts/mutants-nightly.sh b/scripts/mutants-nightly.sh index d3705ba7bf..11d526a3ba 100755 --- a/scripts/mutants-nightly.sh +++ b/scripts/mutants-nightly.sh @@ -59,7 +59,7 @@ git diff "$new_base"..HEAD --unified=0 -- crates >"$OUT/new.diff" || true echo "mutants-nightly: listing workspace mutants" # rbitcoin-bench is an optional host client, not a mutants gate. # CLI --exclude replaces exclude_globs in .cargo/mutants.toml (same glob). -cargo mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' --list >"$OUT/all.txt" +cargo mutants --features mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' --list >"$OUT/all.txt" read -r n_new n_old < <(python3 "$ROOT/scripts/mutants_queue.py" split \ --list "$OUT/all.txt" --diff "$OUT/new.diff" \ @@ -124,7 +124,7 @@ while ((SECONDS < deadline)); do echo "mutants-nightly: batch side=$side at $offset ($requested mutants in $file, ${remain}s left)" set +e timeout --signal=TERM --kill-after=60s "$remain" \ - cargo mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' \ + cargo mutants --features mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' \ --test-workspace=true --baseline=skip --caught --unviable \ -j 1 --timeout "$MUTANT_TIMEOUT" \ "${RE_ARGS[@]}" \ diff --git a/scripts/mutants-queue.test.sh b/scripts/mutants-queue.test.sh index 52a47e8619..c85caea448 100755 --- a/scripts/mutants-queue.test.sh +++ b/scripts/mutants-queue.test.sh @@ -435,5 +435,39 @@ assert_ok "missing state branch falls back to the artifact" \ assert_ok "missing branch and artifact start from an empty cursor" \ grep -E -q '"old_index": ?0' "$tmp/restored-empty.json" +assert_ok "consensus mutants dependency is optional" \ + grep -q 'mutants = { version = "0.0.4", optional = true }' "$ROOT/crates/rbitcoin-consensus/Cargo.toml" +assert_ok "query mutants dependency is optional" \ + grep -q 'mutants = { version = "0.0.4", optional = true }' "$ROOT/crates/rbitcoin-query/Cargo.toml" +assert_ok "consensus mutants feature is the optional dep" \ + grep -q 'mutants = \["dep:mutants"\]' "$ROOT/crates/rbitcoin-consensus/Cargo.toml" +assert_ok "query mutants feature is the optional dep" \ + grep -q 'mutants = \["dep:mutants"\]' "$ROOT/crates/rbitcoin-query/Cargo.toml" +assert_ok "csv mask skip is behind the mutants feature" \ + grep -q 'cfg_attr(feature = "mutants", mutants::skip)' \ + "$ROOT/crates/rbitcoin-consensus/src/script/interpreter.rs" +assert_ok "parent stamp skip is behind the mutants feature" \ + grep -q 'cfg_attr(feature = "mutants", mutants::skip)' \ + "$ROOT/crates/rbitcoin-query/src/stamp.rs" +assert_ok "nightly list builds the mutants feature" \ + grep -q -- '--features mutants --workspace' "$NIGHTLY" +assert_ok "mutants.toml passes the feature to cargo" \ + grep -q 'additional_cargo_args = "--features mutants"' "$TOML" +for toml in "$ROOT"/crates/*/Cargo.toml; do + assert_ok "mutants feature in $(basename "$(dirname "$toml")")" \ + grep -q '^mutants = ' "$toml" +done +set +e +cargo tree -p rbitcoin-consensus -i mutants --prefix none >"$tmp/mutants-tree.txt" 2>&1 +tree_ec=$? +set -e +assert_ok "default consensus build does not link mutants" test "$tree_ec" -ne 0 +set +e +cargo tree -p rbitcoin-consensus --features mutants -i mutants --prefix none >"$tmp/mutants-on.txt" 2>&1 +tree_on=$? +set -e +assert_ok "mutants feature links the attribute crate" test "$tree_on" -eq 0 +assert_ok "mutants feature tree names the crate" grep -q '^mutants ' "$tmp/mutants-on.txt" + echo "$PASS passed, $FAIL failed" test "$FAIL" -eq 0