refactor(core): make Frame and WireError crate-private

Spec §3 (decision D-b) lists `Frame` and `WireError` as private; lib.rs
re-exported both. Only `frame_sender` stays public.

- The pinned vectors in tests/vectors.rs now compile in-crate as a
  `#[cfg(test)] #[path]` module (autotests = false keeps Cargo from also
  building them as an integration test). Every golden vector now checks
  `encode() == GOLDEN` as well as the decode, and new literal vectors pin a
  trailing-byte rejection and an addressed Obs with a nonzero age and an
  `observed_at`.
- A `#[doc(hidden)]` `conformance` module (behind the `conformance`
  feature) gives the fuzz harness byte-level frame encoders/decoders and
  core's local limits without exposing `Frame`. The fuzz_twin test target
  now requires `conformance`, which scripts/ci.sh enables.
- compile_fail doctests pin that `Frame`/`WireError` are unreachable.
- Remove the THROWAWAY gen_missing_golden_vectors test and fix stale
  wire.rs comments about the version/tag byte offsets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 08:44:09 -07:00
co-authored by Claude Opus 5.5
parent d91d7914fc
commit e2717a930d
6 changed files with 324 additions and 140 deletions
+144
View File
@@ -0,0 +1,144 @@
//! Test-only hooks into crate-private wire types and local constants.
//!
//! **Not public API.** Hidden from the docs, behind the `conformance`
//! feature, and outside the 1.x stability promise: anything here may change
//! or vanish in any release.
//!
//! `Frame` and `WireError` are crate-private (spec §3), and the local
//! capacities and timings are `pub(crate)` (decision K). The fuzz harness in
//! `fuzz/` and its proptest twin (`tines-core/tests/fuzz_twin.rs`) still need
//! to build well-formed frames, read the frames a `Node` sends, and size
//! their scenarios by core's local limits. These functions give them exactly
//! that, as byte-level helpers, without exposing the `Frame` enum.
//!
//! Every function is `no_std`, allocation-free and panic-free on any input.
use crate::consts;
use crate::wire::Frame;
use crate::{NodeId, Observation, ObservationId, SigAlgo, Signature};
/// Core's local peer-table capacity (`consts::MAX_PEERS`).
pub const MAX_PEERS: usize = consts::MAX_PEERS;
/// Core's idle-eviction window (`consts::PEER_IDLE_MS`).
pub const PEER_IDLE_MS: u64 = consts::PEER_IDLE_MS;
/// Core's summary cadence (`consts::SUMMARY_INTERVAL_MS`).
pub const SUMMARY_INTERVAL_MS: u64 = consts::SUMMARY_INTERVAL_MS;
/// Core's per-peer send-queue and bounce-table capacity (`consts::SEND_QUEUE_CAP`).
pub const SEND_QUEUE_CAP: usize = consts::SEND_QUEUE_CAP;
/// Core's `Delivered` event-queue capacity (`consts::EVENT_QUEUE_CAP`).
pub const EVENT_QUEUE_CAP: usize = consts::EVENT_QUEUE_CAP;
/// Core's MTU-sweep lower bound (`consts::MIN_MTU`).
pub const MIN_MTU: usize = consts::MIN_MTU;
/// Bytes a `Frag` frame may add around its data (`frag::FRAG_OVERHEAD`).
pub const FRAG_OVERHEAD: usize = crate::frag::FRAG_OVERHEAD;
/// Most fragments one observation may be split into (`frag::MAX_FRAGMENTS`).
pub const MAX_FRAGMENTS: usize = crate::frag::MAX_FRAGMENTS;
fn encode(frame: &Frame, out: &mut [u8]) -> Option<usize> {
frame.encode(out).ok()
}
/// Encodes a `Summary` frame into `out`. `None` if `bits` is longer than
/// `SUMMARY_BYTES` or `out` is too small.
pub fn encode_summary(from: NodeId, salt: u32, bits: &[u8], out: &mut [u8]) -> Option<usize> {
let bits = heapless::Vec::from_slice(bits).ok()?;
encode(&Frame::Summary { from, salt, bits }, out)
}
/// Encodes an `Obs` frame carrying `obs` into `out`. `None` if `out` is too
/// small.
pub fn encode_obs(from: NodeId, obs: &Observation, out: &mut [u8]) -> Option<usize> {
let obs = obs.clone();
encode(&Frame::Obs { from, obs }, out)
}
/// Encodes a `Frag` frame into `out`. `None` if `data` is longer than
/// `MAX_FRAME` or `out` is too small. `index`/`count` are not validated: an
/// adversarial harness sends inconsistent ones on purpose.
pub fn encode_frag(
from: NodeId,
id: ObservationId,
index: u8,
count: u8,
data: &[u8],
out: &mut [u8],
) -> Option<usize> {
let data = heapless::Vec::from_slice(data).ok()?;
encode(
&Frame::Frag {
from,
id,
index,
count,
data,
},
out,
)
}
/// Encodes an `Ack` frame into `out`. `None` if `ids` is longer than
/// `ACK_IDS_CAP` or `out` is too small.
pub fn encode_ack(from: NodeId, ids: &[ObservationId], out: &mut [u8]) -> Option<usize> {
let ids = heapless::Vec::from_slice(ids).ok()?;
encode(&Frame::Ack { from, ids }, out)
}
/// Encodes `obs` alone (no frame envelope) into `out`: the byte stream a
/// sender cuts into `Frag` data. `None` if `out` is too small.
pub fn encode_observation(obs: &Observation, out: &mut [u8]) -> Option<usize> {
postcard::to_slice(obs, out).ok().map(|s| s.len())
}
/// The sender of `bytes` if they decode as a complete, canonical frame — the
/// same decode the exchange runs — else `None` (the exchange counts such a
/// frame as `VersionMismatch` or `Malformed`).
pub fn decoded_sender(bytes: &[u8]) -> Option<NodeId> {
Frame::decode(bytes).ok().map(|f| f.sender())
}
/// The observation in `bytes` if they decode as an `Obs` frame.
pub fn obs_of(bytes: &[u8]) -> Option<Observation> {
match Frame::decode(bytes).ok()? {
Frame::Obs { obs, .. } => Some(obs),
_ => None,
}
}
/// `(id, index, count)` of `bytes` if they decode as a `Frag` frame.
pub fn frag_of(bytes: &[u8]) -> Option<(ObservationId, u8, u8)> {
match Frame::decode(bytes).ok()? {
Frame::Frag {
id, index, count, ..
} => Some((id, index, count)),
_ => None,
}
}
/// Calls `f` on each id of `bytes` if they decode as an `Ack` frame, and
/// returns whether they did.
pub fn ack_of(bytes: &[u8], f: &mut dyn FnMut(&ObservationId)) -> bool {
match Frame::decode(bytes) {
Ok(Frame::Ack { ids, .. }) => {
ids.iter().for_each(f);
true
}
_ => false,
}
}
/// Whether `bytes` decode as a `Summary` frame.
pub fn is_summary(bytes: &[u8]) -> bool {
matches!(Frame::decode(bytes), Ok(Frame::Summary { .. }))
}
/// Builds a [`Signature`] (a `#[non_exhaustive]` struct only core can
/// construct). `None` if `bytes` is longer than `MAX_SIG`. The shape rules
/// (version `0`, a non-64-byte Ed25519) are not checked: a harness builds
/// invalid signatures on purpose to see them rejected on decode.
pub fn signature(version: u8, algo: SigAlgo, bytes: &[u8]) -> Option<Signature> {
Some(Signature {
version,
algo,
bytes: heapless::Vec::from_slice(bytes).ok()?,
})
}
+12 -1
View File
@@ -7,6 +7,9 @@ extern crate alloc;
pub mod belief;
mod bloom;
#[cfg(feature = "conformance")]
#[doc(hidden)]
pub mod conformance;
pub mod consts;
mod exchange;
mod frag;
@@ -28,10 +31,18 @@ pub use observation::{
};
pub use store::{Store, StoreError, TrimPolicy, WallClockStore};
pub use transport::{Event, FrameBuf, SendError, Transport};
pub use wire::{frame_sender, Frame, WireError};
pub use wire::frame_sender;
#[cfg(feature = "alloc")]
pub use mem_store::MemStore;
#[cfg(test)]
extern crate std;
// The pinned wire vectors (spec §7) need the crate-private `Frame` and
// `WireError`, so they compile as a unit-test module rather than as an
// integration test (`autotests = false` in Cargo.toml keeps Cargo from also
// building them as one).
#[cfg(test)]
#[path = "../tests/vectors.rs"]
mod vectors;
+21 -64
View File
@@ -14,13 +14,21 @@ use serde::{Deserialize, Serialize};
/// avoid per-frame heap allocation in the `no_std` core; this keeps the enum
/// large, so the informative `large_enum_variant` lint is suppressed here.
///
/// Exposed publicly (see [`crate::Frame`]) so that `tines-core/tests/vectors.rs`
/// can decode the pinned golden wire vectors and assert on the decoded shape.
/// The wire layout (version byte, positional postcard tags, reserved Signature
/// slot) is the frozen contract this crate guarantees across 0.x → 1.0.
/// Crate-private (spec §3): the type is not API, but its encoding is. The
/// wire layout (version byte, positional postcard tags, reserved Signature
/// slot) is frozen for wire v1 and pinned byte-for-byte by
/// `tines-core/tests/vectors.rs`, which runs as an in-crate test module.
///
/// ```compile_fail
/// use tines_core::Frame; // crate-private: not reachable from outside core
/// ```
///
/// ```compile_fail
/// use tines_core::WireError; // crate-private too
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum Frame {
pub(crate) enum Frame {
/// A broadcast bloom summary of a node's observation-id set.
Summary {
/// The sending node.
@@ -143,13 +151,10 @@ pub fn frame_sender(bytes: &[u8]) -> Option<NodeId> {
Some(from)
}
/// Errors produced while (de)serializing frames on the wire.
///
/// Exposed publicly (see [`crate::WireError`]) so that `tines-core/tests/vectors.rs`
/// can assert on the exact decode error (e.g. `VersionMismatch`) for the pinned
/// malformed vectors.
/// Errors produced while (de)serializing frames on the wire. Crate-private
/// (spec §3); the exchange reports decode failures as [`crate::DropReason`].
#[derive(Debug, PartialEq, Eq)]
pub enum WireError {
pub(crate) enum WireError {
/// The output buffer was too small to hold the serialized frame.
TooLarge,
/// The input bytes were malformed, truncated, or not a valid `Frame`.
@@ -315,7 +320,8 @@ mod tests {
#[test]
fn frame_sender_peeks_from_field() {
// Version byte 0, variant tag 1, then `from: [3;16]` at bytes 2..18.
// Version byte (`WIRE_VERSION` = 4) at byte 0, variant tag 0
// (Summary) at byte 1, then `from: [3;16]` at bytes 2..18.
const GOLDEN: [u8; 201] = [
4u8, 0u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8,
3u8, 239u8, 253u8, 182u8, 245u8, 13u8, 128u8, 4u8, 170u8, 170u8, 170u8, 170u8, 170u8,
@@ -340,9 +346,9 @@ mod tests {
#[test]
fn frame_sender_reads_from_fixed_offset_not_just_the_start() {
// The leading byte (the externally-tagged variant tag) is skipped, so a
// payload whose first byte is not the sender must still yield the `from`
// recorded at byte 1.
// The two leading bytes (the version byte and the externally-tagged
// variant tag) are skipped, so a payload whose first bytes are not the
// sender must still yield the `from` recorded at bytes 2..18.
let bytes = {
let mut b = [0u8; 32];
b[0] = consts::WIRE_VERSION; // version slot (skipped by frame_sender)
@@ -514,53 +520,4 @@ mod tests {
other => panic!("expected Obs, got {other:?}"),
}
}
#[test]
fn gen_missing_golden_vectors() {
// THROWAWAY: encode canonical frames and print hex for vectors.rs.
let enc = |f: Frame| {
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
let hex: std::string::String =
buf[..n].iter().map(|b| std::format!("{b:02x}")).collect();
std::println!("{n} {hex}");
};
// unknown-algo Obs
let f = Frame::Obs {
from: [1; 16],
obs: Observation {
observer: [1; 16],
seq: 1,
age_ms: 0,
observed_at: None,
topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(&[0u8; consts::MAX_PAYLOAD]).unwrap(),
dest: None,
sig: Some(Signature {
version: 1,
algo: SigAlgo::Unknown(7),
bytes: Vec::from_slice(&[0u8; 64]).unwrap(),
}),
},
};
enc(f);
// Frag
let f = Frame::Frag {
from: [1; 16],
id: [2; 16],
index: 0,
count: 1,
data: Vec::from_slice(&[0x5Cu8; 64]).unwrap(),
};
enc(f);
// Ack
let ids =
Vec::<ObservationId, { consts::ACK_IDS_CAP }>::from_slice(&[[2u8; 16], [3u8; 16]])
.unwrap();
let f = Frame::Ack { from: [1; 16], ids };
enc(f);
}
}