Skip to main content

fsqlite_core/
replication_sender.rs

1//! §3.4.2 Fountain-Coded Replication Sender (bd-1hi.13).
2//!
3//! Implements the sender-side state machine for fountain-coded database
4//! replication using RaptorQ encoding over UDP.
5//!
6//! State machine: IDLE → ENCODING → STREAMING → COMPLETE
7//!
8//! Changeset encoding is deterministic, self-delimiting, and uses
9//! domain-separated BLAKE3 for changeset identity.
10
11use std::fmt;
12
13use fsqlite_error::{FrankenError, Result};
14use tracing::{debug, error, info, warn};
15
16use crate::source_block_partition::K_MAX;
17
18const BEAD_ID: &str = "bd-1hi.13";
19
20// ---------------------------------------------------------------------------
21// Constants
22// ---------------------------------------------------------------------------
23
24/// Changeset header magic bytes.
25pub const CHANGESET_MAGIC: [u8; 4] = *b"FSRP";
26
27/// Changeset format version.
28pub const CHANGESET_VERSION: u16 = 1;
29
30/// BLAKE3 domain separation context for changeset identity.
31pub const CHANGESET_DOMAIN: &str = "fsqlite:replication:changeset:v1";
32
33/// Replication packet header size (bytes).
34pub const REPLICATION_HEADER_SIZE: usize = 72;
35
36/// Legacy replication header size from bd-1hi.13 (bytes).
37pub const REPLICATION_HEADER_SIZE_LEGACY: usize = 24;
38
39/// Protocol magic for the fixed-size replication packet header.
40pub const REPLICATION_PROTOCOL_MAGIC: [u8; 4] = *b"FSRP";
41
42/// Current fixed-header packet protocol version.
43pub const REPLICATION_PROTOCOL_VERSION_V2: u8 = 2;
44
45/// Fixed-size V2 replication header length encoded on wire.
46pub const REPLICATION_HEADER_SIZE_V2: usize = REPLICATION_HEADER_SIZE;
47/// Fixed-size V2 replication header length encoded on wire (`u16` form).
48pub const REPLICATION_HEADER_SIZE_V2_U16: u16 = 72;
49
50/// Header flag: packet carries an authentication tag.
51pub const REPLICATION_FLAG_AUTH_PRESENT: u8 = 0b0000_0001;
52
53/// Domain separator for packet authentication tags.
54pub const REPLICATION_PACKET_AUTH_DOMAIN: &str = "fsqlite:replication:packet-auth:v1";
55
56/// Maximum UDP application payload (IPv4).
57pub const MAX_UDP_PAYLOAD: usize = 65_507;
58
59/// Maximum symbol size for replication: `MAX_UDP_PAYLOAD - REPLICATION_HEADER_SIZE`.
60pub const MAX_REPLICATION_SYMBOL_SIZE: usize = MAX_UDP_PAYLOAD - REPLICATION_HEADER_SIZE;
61
62/// Recommended MTU-safe symbol size for Ethernet.
63/// 1500 MTU - 20 IPv4 - 8 UDP - 72 replication header = 1400.
64pub const MTU_SAFE_SYMBOL_SIZE: u16 = 1400;
65
66/// Default maximum ISI multiplier for streaming stop.
67pub const DEFAULT_MAX_ISI_MULTIPLIER: u32 = 2;
68
69/// Default hard cap for a single remote message (4 MiB, §4.19.6).
70pub const DEFAULT_RPC_MESSAGE_CAP_BYTES: usize = 4 * 1024 * 1024;
71
72/// HTTP/2 default: max concurrent streams.
73pub const DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 256;
74
75/// HTTP/2 default: maximum compressed header list size (64 KiB).
76pub const DEFAULT_HTTP2_MAX_HEADER_LIST_SIZE: usize = 65_536;
77
78/// HTTP/2 default: CONTINUATION timeout in milliseconds (5s).
79pub const DEFAULT_HTTP2_CONTINUATION_TIMEOUT_MS: u64 = 5_000;
80
81/// HTTP/2 default: absolute header fragment cap (256 KiB).
82pub const DEFAULT_HTTP2_HEADER_FRAGMENT_CAP: usize = 262_144;
83
84/// Default handshake timeout in milliseconds.
85pub const DEFAULT_HANDSHAKE_TIMEOUT_MS: u64 = 500;
86
87/// Changeset header size in bytes.
88pub const CHANGESET_HEADER_SIZE: usize = 4 + 2 + 4 + 4 + 8; // magic + version + page_size + n_pages + total_len = 22
89
90// ---------------------------------------------------------------------------
91// §4.19.6 Network Policy + Deterministic VirtualTcp
92// ---------------------------------------------------------------------------
93
94/// Transport security mode for remote networking.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum TransportSecurityMode {
97    /// TLS transport via rustls.
98    RustlsTls,
99    /// Plaintext transport (only for explicit local development opt-in).
100    Plaintext,
101}
102
103/// Enforced HTTP/2 hard limits.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Http2HardLimits {
106    pub max_concurrent_streams: u32,
107    pub max_header_list_size: usize,
108    pub continuation_timeout_ms: u64,
109    pub header_fragment_cap: usize,
110}
111
112impl Default for Http2HardLimits {
113    fn default() -> Self {
114        Self {
115            max_concurrent_streams: DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS,
116            max_header_list_size: DEFAULT_HTTP2_MAX_HEADER_LIST_SIZE,
117            continuation_timeout_ms: DEFAULT_HTTP2_CONTINUATION_TIMEOUT_MS,
118            header_fragment_cap: DEFAULT_HTTP2_HEADER_FRAGMENT_CAP,
119        }
120    }
121}
122
123/// Networking stack policy for remote effects and replication transport.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct NetworkStackConfig {
126    pub security: TransportSecurityMode,
127    pub explicit_plaintext_opt_in: bool,
128    pub handshake_timeout_ms: u64,
129    pub message_size_cap_bytes: usize,
130    pub http2: Http2HardLimits,
131}
132
133impl Default for NetworkStackConfig {
134    fn default() -> Self {
135        Self {
136            security: TransportSecurityMode::RustlsTls,
137            explicit_plaintext_opt_in: false,
138            handshake_timeout_ms: DEFAULT_HANDSHAKE_TIMEOUT_MS,
139            message_size_cap_bytes: DEFAULT_RPC_MESSAGE_CAP_BYTES,
140            http2: Http2HardLimits::default(),
141        }
142    }
143}
144
145impl NetworkStackConfig {
146    /// Build plaintext config for explicit local development.
147    ///
148    /// # Errors
149    ///
150    /// Returns `FrankenError::Unsupported` when plaintext is requested
151    /// without explicit opt-in.
152    pub fn plaintext_local_dev(explicit_opt_in: bool) -> Result<Self> {
153        if !explicit_opt_in {
154            return Err(FrankenError::Unsupported);
155        }
156        Ok(Self {
157            security: TransportSecurityMode::Plaintext,
158            explicit_plaintext_opt_in: true,
159            ..Self::default()
160        })
161    }
162
163    /// Validate the transport security policy.
164    ///
165    /// # Errors
166    ///
167    /// Returns `FrankenError::Unsupported` if plaintext is not explicitly opted in.
168    pub fn validate_security(&self) -> Result<()> {
169        if self.security == TransportSecurityMode::Plaintext && !self.explicit_plaintext_opt_in {
170            return Err(FrankenError::Unsupported);
171        }
172        Ok(())
173    }
174
175    /// Validate stream concurrency against HTTP/2 hard limits.
176    ///
177    /// # Errors
178    ///
179    /// Returns `FrankenError::Busy` when `streams` exceeds the configured maximum.
180    pub fn validate_concurrent_streams(&self, streams: u32) -> Result<()> {
181        if streams > self.http2.max_concurrent_streams {
182            return Err(FrankenError::Busy);
183        }
184        Ok(())
185    }
186
187    /// Validate HTTP header-list size.
188    ///
189    /// # Errors
190    ///
191    /// Returns `FrankenError::TooBig` if header bytes exceed configured limit.
192    pub fn validate_header_list_size(&self, header_bytes: usize) -> Result<()> {
193        if header_bytes > self.http2.max_header_list_size {
194            return Err(FrankenError::TooBig);
195        }
196        Ok(())
197    }
198
199    /// Validate elapsed time for HTTP/2 continuation.
200    ///
201    /// # Errors
202    ///
203    /// Returns `FrankenError::BusyRecovery` when continuation elapsed time
204    /// exceeds the configured timeout.
205    pub fn validate_continuation_elapsed(&self, elapsed_ms: u64) -> Result<()> {
206        if elapsed_ms > self.http2.continuation_timeout_ms {
207            return Err(FrankenError::BusyRecovery);
208        }
209        Ok(())
210    }
211
212    /// Validate elapsed handshake time against timeout budget.
213    ///
214    /// # Errors
215    ///
216    /// Returns `FrankenError::BusyRecovery` when elapsed time exceeds budget.
217    pub fn validate_handshake_elapsed(&self, elapsed_ms: u64) -> Result<()> {
218        if elapsed_ms > self.handshake_timeout_ms {
219            return Err(FrankenError::BusyRecovery);
220        }
221        Ok(())
222    }
223
224    /// Validate message size against the hard cap.
225    ///
226    /// # Errors
227    ///
228    /// Returns `FrankenError::TooBig` when `message_bytes` exceeds the cap.
229    pub fn validate_message_size(&self, message_bytes: usize) -> Result<()> {
230        if message_bytes > self.message_size_cap_bytes {
231            return Err(FrankenError::TooBig);
232        }
233        Ok(())
234    }
235}
236
237/// Fault profile for deterministic in-memory VirtualTcp transport.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub struct VirtualTcpFaultProfile {
240    pub drop_per_million: u32,
241    pub reorder_per_million: u32,
242    pub corrupt_per_million: u32,
243}
244
245impl VirtualTcpFaultProfile {
246    /// Validate rates in parts-per-million (`0..=1_000_000`).
247    ///
248    /// # Errors
249    ///
250    /// Returns `FrankenError::OutOfRange` when any rate is above 1_000_000.
251    pub fn validate(&self) -> Result<()> {
252        const PPM_MAX: u32 = 1_000_000;
253        if self.drop_per_million > PPM_MAX {
254            return Err(FrankenError::OutOfRange {
255                what: "drop_per_million".to_owned(),
256                value: self.drop_per_million.to_string(),
257            });
258        }
259        if self.reorder_per_million > PPM_MAX {
260            return Err(FrankenError::OutOfRange {
261                what: "reorder_per_million".to_owned(),
262                value: self.reorder_per_million.to_string(),
263            });
264        }
265        if self.corrupt_per_million > PPM_MAX {
266            return Err(FrankenError::OutOfRange {
267                what: "corrupt_per_million".to_owned(),
268                value: self.corrupt_per_million.to_string(),
269            });
270        }
271        Ok(())
272    }
273}
274
275/// Trace event kind for deterministic VirtualTcp replay.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum VirtualTcpTraceKind {
278    Dropped,
279    BufferedForReorder,
280    Delivered,
281    DeliveredCorrupt,
282    FlushedReordered,
283}
284
285/// Deterministic trace event emitted by VirtualTcp.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct VirtualTcpTraceEvent {
288    pub seq: u64,
289    pub kind: VirtualTcpTraceKind,
290    pub payload_hash: u64,
291}
292
293/// Deterministic in-memory network shim for lab/DPOR.
294#[derive(Debug, Clone)]
295pub struct VirtualTcp {
296    state: u64,
297    seq: u64,
298    faults: VirtualTcpFaultProfile,
299    pending_reorder: Option<Vec<u8>>,
300    trace: Vec<VirtualTcpTraceEvent>,
301}
302
303impl VirtualTcp {
304    /// Construct a new deterministic VirtualTcp instance.
305    ///
306    /// # Errors
307    ///
308    /// Returns `FrankenError::OutOfRange` when fault probabilities are invalid.
309    pub fn new(seed: u64, faults: VirtualTcpFaultProfile) -> Result<Self> {
310        faults.validate()?;
311        Ok(Self {
312            state: seed,
313            seq: 0,
314            faults,
315            pending_reorder: None,
316            trace: Vec::new(),
317        })
318    }
319
320    /// Return deterministic trace events for replay/debugging.
321    #[must_use]
322    pub fn trace(&self) -> &[VirtualTcpTraceEvent] {
323        &self.trace
324    }
325
326    /// Transmit one payload through deterministic drop/reorder/corrupt rules.
327    ///
328    /// Returns zero, one, or two delivered payloads (reorder flush path).
329    #[must_use]
330    pub fn transmit(&mut self, payload: &[u8]) -> Vec<Vec<u8>> {
331        self.seq = self.seq.saturating_add(1);
332
333        if self.coin_flip(self.faults.drop_per_million) {
334            self.push_trace(VirtualTcpTraceKind::Dropped, payload);
335            return Vec::new();
336        }
337
338        let mut wire = payload.to_vec();
339        let corrupted = if !wire.is_empty() && self.coin_flip(self.faults.corrupt_per_million) {
340            let idx = (self.next_u32() as usize) % wire.len();
341            wire[idx] ^= 0x01;
342            true
343        } else {
344            false
345        };
346
347        if self.coin_flip(self.faults.reorder_per_million) && self.pending_reorder.is_none() {
348            self.push_trace(VirtualTcpTraceKind::BufferedForReorder, &wire);
349            self.pending_reorder = Some(wire);
350            return Vec::new();
351        }
352
353        let mut out = Vec::with_capacity(2);
354        if let Some(previous) = self.pending_reorder.take() {
355            let kind = if corrupted {
356                VirtualTcpTraceKind::DeliveredCorrupt
357            } else {
358                VirtualTcpTraceKind::Delivered
359            };
360            self.push_trace(kind, &wire);
361            out.push(wire);
362            self.push_trace(VirtualTcpTraceKind::FlushedReordered, &previous);
363            out.push(previous);
364            return out;
365        }
366
367        let kind = if corrupted {
368            VirtualTcpTraceKind::DeliveredCorrupt
369        } else {
370            VirtualTcpTraceKind::Delivered
371        };
372        self.push_trace(kind, &wire);
373        out.push(wire);
374        out
375    }
376
377    /// Flush any pending reordered payload.
378    pub fn flush(&mut self) -> Option<Vec<u8>> {
379        let pending = self.pending_reorder.take()?;
380        self.seq = self.seq.saturating_add(1);
381        self.push_trace(VirtualTcpTraceKind::FlushedReordered, &pending);
382        Some(pending)
383    }
384
385    fn push_trace(&mut self, kind: VirtualTcpTraceKind, payload: &[u8]) {
386        self.trace.push(VirtualTcpTraceEvent {
387            seq: self.seq,
388            kind,
389            payload_hash: xxhash_rust::xxh3::xxh3_64(payload),
390        });
391    }
392
393    fn coin_flip(&mut self, per_million: u32) -> bool {
394        const PPM_MAX: u32 = 1_000_000;
395        if per_million == 0 {
396            return false;
397        }
398        if per_million >= PPM_MAX {
399            return true;
400        }
401        self.next_u32() % PPM_MAX < per_million
402    }
403
404    fn next_u32(&mut self) -> u32 {
405        // Deterministic LCG for lab replay.
406        self.state = self
407            .state
408            .wrapping_mul(6_364_136_223_846_793_005)
409            .wrapping_add(1);
410        (self.state >> 32) as u32
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Changeset Encoding
416// ---------------------------------------------------------------------------
417
418/// Self-delimiting changeset header (§3.4.2).
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub struct ChangesetHeader {
421    pub magic: [u8; 4],
422    pub version: u16,
423    pub page_size: u32,
424    pub n_pages: u32,
425    pub total_len: u64,
426}
427
428impl ChangesetHeader {
429    /// Encode to little-endian bytes.
430    #[must_use]
431    pub fn to_bytes(&self) -> [u8; CHANGESET_HEADER_SIZE] {
432        let mut buf = [0_u8; CHANGESET_HEADER_SIZE];
433        buf[0..4].copy_from_slice(&self.magic);
434        buf[4..6].copy_from_slice(&self.version.to_le_bytes());
435        buf[6..10].copy_from_slice(&self.page_size.to_le_bytes());
436        buf[10..14].copy_from_slice(&self.n_pages.to_le_bytes());
437        buf[14..22].copy_from_slice(&self.total_len.to_le_bytes());
438        buf
439    }
440
441    /// Decode from little-endian bytes.
442    ///
443    /// # Errors
444    ///
445    /// Returns error if magic or version mismatch.
446    pub fn from_bytes(buf: &[u8; CHANGESET_HEADER_SIZE]) -> Result<Self> {
447        let magic: [u8; 4] = buf[0..4].try_into().expect("4 bytes");
448        if magic != CHANGESET_MAGIC {
449            return Err(FrankenError::DatabaseCorrupt {
450                detail: format!("changeset magic mismatch: expected FSRP, got {magic:?}"),
451            });
452        }
453        let version = u16::from_le_bytes(buf[4..6].try_into().expect("2 bytes"));
454        if version != CHANGESET_VERSION {
455            return Err(FrankenError::DatabaseCorrupt {
456                detail: format!(
457                    "changeset version mismatch: expected {CHANGESET_VERSION}, got {version}"
458                ),
459            });
460        }
461        let page_size = u32::from_le_bytes(buf[6..10].try_into().expect("4 bytes"));
462        let n_pages = u32::from_le_bytes(buf[10..14].try_into().expect("4 bytes"));
463        let total_len = u64::from_le_bytes(buf[14..22].try_into().expect("8 bytes"));
464        Ok(Self {
465            magic,
466            version,
467            page_size,
468            n_pages,
469            total_len,
470        })
471    }
472}
473
474/// A single page entry in the changeset.
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub struct PageEntry {
477    pub page_number: u32,
478    pub page_xxh3: u64,
479    pub page_bytes: Vec<u8>,
480}
481
482impl PageEntry {
483    /// Create a page entry, computing the xxh3 checksum.
484    #[must_use]
485    pub fn new(page_number: u32, page_bytes: Vec<u8>) -> Self {
486        let page_xxh3 = xxhash_rust::xxh3::xxh3_64(&page_bytes);
487        Self {
488            page_number,
489            page_xxh3,
490            page_bytes,
491        }
492    }
493
494    /// Validate that the stored xxh3 matches the page bytes.
495    #[must_use]
496    pub fn validate_xxh3(&self) -> bool {
497        xxhash_rust::xxh3::xxh3_64(&self.page_bytes) == self.page_xxh3
498    }
499}
500
501fn auth_tags_equal(lhs: &[u8; 16], rhs: &[u8; 16]) -> bool {
502    lhs.iter()
503        .zip(rhs.iter())
504        .fold(0_u8, |acc, (&left, &right)| acc | (left ^ right))
505        == 0
506}
507
508/// 128-bit changeset identifier (truncated BLAKE3).
509#[derive(Clone, Copy, PartialEq, Eq, Hash)]
510pub struct ChangesetId([u8; 16]);
511
512impl ChangesetId {
513    /// Bytes of the identifier.
514    #[must_use]
515    pub const fn as_bytes(&self) -> &[u8; 16] {
516        &self.0
517    }
518
519    /// Create from raw bytes.
520    #[must_use]
521    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
522        Self(bytes)
523    }
524}
525
526impl fmt::Debug for ChangesetId {
527    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528        write!(f, "ChangesetId(")?;
529        for byte in &self.0 {
530            write!(f, "{byte:02x}")?;
531        }
532        write!(f, ")")
533    }
534}
535
536/// Compute the changeset identifier: `Trunc128(BLAKE3(domain || changeset_bytes))`.
537#[must_use]
538pub fn compute_changeset_id(changeset_bytes: &[u8]) -> ChangesetId {
539    let mut hasher = blake3::Hasher::new();
540    hasher.update(CHANGESET_DOMAIN.as_bytes());
541    hasher.update(changeset_bytes);
542    let hash = hasher.finalize();
543    let mut id = [0_u8; 16];
544    id.copy_from_slice(&hash.as_bytes()[..16]);
545    ChangesetId(id)
546}
547
548/// Derive the deterministic RaptorQ seed from a changeset identifier.
549#[must_use]
550pub fn derive_seed_from_changeset_id(id: &ChangesetId) -> u64 {
551    xxhash_rust::xxh3::xxh3_64(id.as_bytes())
552}
553
554/// Generate a deterministic placeholder repair symbol (fallback when the
555/// RaptorQ encoder cannot be constructed). NOT a real RFC 6330 repair symbol;
556/// data encoded with these placeholders cannot be recovered.
557#[allow(clippy::cast_possible_truncation)]
558pub(crate) fn generate_deterministic_placeholder(seed: u64, isi: u32, t: usize) -> Vec<u8> {
559    let repair_seed = seed.wrapping_add(u64::from(isi));
560    let mut data = vec![0_u8; t];
561    for (i, byte) in data.iter_mut().enumerate() {
562        let mixed = repair_seed
563            .wrapping_mul(0x9E37_79B9_7F4A_7C15)
564            .wrapping_add(i as u64);
565        *byte = (mixed >> 32) as u8;
566    }
567    data
568}
569
570/// Compute `K_source = ceil(F / T_replication)` for a payload length `F`.
571///
572/// This is the normative symbol-count mapping for replication object sizing.
573///
574/// # Errors
575///
576/// Returns `FrankenError::OutOfRange` if `symbol_size` is 0.
577pub fn compute_k_source(total_bytes: usize, symbol_size: u16) -> Result<u64> {
578    if symbol_size == 0 {
579        return Err(FrankenError::OutOfRange {
580            what: "symbol_size".to_owned(),
581            value: "0".to_owned(),
582        });
583    }
584    let f = u64::try_from(total_bytes).map_err(|_| FrankenError::OutOfRange {
585        what: "total_bytes".to_owned(),
586        value: total_bytes.to_string(),
587    })?;
588    let t = u64::from(symbol_size);
589    Ok(f.div_ceil(t))
590}
591
592/// Canonicalize page entries for deterministic `changeset_bytes`.
593///
594/// Sorting by page number is the primary key. Tie-breakers remove dependence
595/// on input iteration order (e.g., hash-map traversal) for duplicate page
596/// numbers.
597fn canonicalize_changeset_pages(pages: &mut [PageEntry]) {
598    pages.sort_by(|lhs, rhs| {
599        lhs.page_number
600            .cmp(&rhs.page_number)
601            .then_with(|| lhs.page_xxh3.cmp(&rhs.page_xxh3))
602            .then_with(|| lhs.page_bytes.cmp(&rhs.page_bytes))
603    });
604}
605
606/// Encode pages into a deterministic changeset byte stream.
607///
608/// Canonicalization rule:
609/// - sort pages by `(page_number, page_xxh3, page_bytes)` before encoding.
610/// - this removes non-deterministic map-iteration effects from `changeset_bytes`.
611///
612/// The encoded stream is self-delimiting via the `total_len` field in the
613/// header.
614///
615/// # Errors
616///
617/// Returns error if `page_size` is 0 or pages are empty.
618pub fn encode_changeset(page_size: u32, pages: &mut [PageEntry]) -> Result<Vec<u8>> {
619    if pages.is_empty() {
620        return Err(FrankenError::OutOfRange {
621            what: "pages".to_owned(),
622            value: "0".to_owned(),
623        });
624    }
625    if page_size == 0 {
626        return Err(FrankenError::OutOfRange {
627            what: "page_size".to_owned(),
628            value: "0".to_owned(),
629        });
630    }
631    let page_size_usize = usize::try_from(page_size).map_err(|_| FrankenError::OutOfRange {
632        what: "page_size".to_owned(),
633        value: page_size.to_string(),
634    })?;
635
636    for (index, page) in pages.iter().enumerate() {
637        if page.page_bytes.len() != page_size_usize {
638            return Err(FrankenError::OutOfRange {
639                what: format!("pages[{index}].page_bytes.len"),
640                value: format!("{} (expected {page_size_usize})", page.page_bytes.len()),
641            });
642        }
643        if !page.validate_xxh3() {
644            return Err(FrankenError::DatabaseCorrupt {
645                detail: format!(
646                    "page {} xxh3 mismatch before changeset encoding",
647                    page.page_number
648                ),
649            });
650        }
651    }
652
653    canonicalize_changeset_pages(pages);
654
655    let n_pages = u32::try_from(pages.len()).map_err(|_| FrankenError::OutOfRange {
656        what: "n_pages".to_owned(),
657        value: pages.len().to_string(),
658    })?;
659
660    // Per-page entry size: 4 (page_number) + 8 (xxh3) + page_size
661    let entry_size = 4_u64
662        .checked_add(8)
663        .and_then(|value| value.checked_add(u64::from(page_size)))
664        .ok_or_else(|| FrankenError::OutOfRange {
665            what: "entry_size".to_owned(),
666            value: format!("page_size={page_size}"),
667        })?;
668    let payload_len =
669        entry_size
670            .checked_mul(u64::from(n_pages))
671            .ok_or_else(|| FrankenError::OutOfRange {
672                what: "changeset payload size".to_owned(),
673                value: format!("entry_size={entry_size}, n_pages={n_pages}"),
674            })?;
675    let total_len = (CHANGESET_HEADER_SIZE as u64)
676        .checked_add(payload_len)
677        .ok_or_else(|| FrankenError::OutOfRange {
678            what: "changeset total_len".to_owned(),
679            value: format!("payload_len={payload_len}"),
680        })?;
681
682    let header = ChangesetHeader {
683        magic: CHANGESET_MAGIC,
684        version: CHANGESET_VERSION,
685        page_size,
686        n_pages,
687        total_len,
688    };
689
690    let buf_cap = usize::try_from(total_len).map_err(|_| FrankenError::OutOfRange {
691        what: "changeset total_len".to_owned(),
692        value: total_len.to_string(),
693    })?;
694    let mut buf = Vec::with_capacity(buf_cap);
695    buf.extend_from_slice(&header.to_bytes());
696
697    for page in pages.iter() {
698        buf.extend_from_slice(&page.page_number.to_le_bytes());
699        buf.extend_from_slice(&page.page_xxh3.to_le_bytes());
700        buf.extend_from_slice(&page.page_bytes);
701    }
702
703    debug!(
704        bead_id = BEAD_ID,
705        n_pages, page_size, total_len, "encoded changeset"
706    );
707
708    debug_assert_eq!(buf.len() as u64, total_len);
709    Ok(buf)
710}
711
712// ---------------------------------------------------------------------------
713// Sharding
714// ---------------------------------------------------------------------------
715
716/// A shard of a large changeset that fits within a single RaptorQ source block.
717#[derive(Debug, Clone)]
718pub struct ChangesetShard {
719    /// The changeset bytes for this shard.
720    pub changeset_bytes: Vec<u8>,
721    /// The changeset identifier for this shard.
722    pub changeset_id: ChangesetId,
723    /// The deterministic seed for RaptorQ encoding.
724    pub seed: u64,
725    /// Number of source symbols (K_source) for this shard.
726    pub k_source: u32,
727}
728
729/// Shard a changeset into pieces that each fit within K_MAX source symbols.
730///
731/// If the changeset fits in one block, returns a single shard.
732///
733/// Large changesets use deterministic contiguous byte-range sharding:
734/// - max shard payload = `K_MAX * T_replication`
735/// - shard `i` = bytes `[i * max_payload .. min((i+1) * max_payload, F))`
736/// - each shard gets its own `changeset_id` and seed derived from shard bytes
737///
738/// # Errors
739///
740/// Returns error if `symbol_size` is 0.
741pub fn shard_changeset(changeset_bytes: Vec<u8>, symbol_size: u16) -> Result<Vec<ChangesetShard>> {
742    let t = u64::from(symbol_size);
743    let f = u64::try_from(changeset_bytes.len()).map_err(|_| FrankenError::OutOfRange {
744        what: "changeset_bytes".to_owned(),
745        value: changeset_bytes.len().to_string(),
746    })?;
747    let k_source_total = compute_k_source(changeset_bytes.len(), symbol_size)?;
748
749    if k_source_total <= u64::from(K_MAX) {
750        let id = compute_changeset_id(&changeset_bytes);
751        let seed = derive_seed_from_changeset_id(&id);
752        let k_source = u32::try_from(k_source_total).expect("checked <= K_MAX");
753        info!(
754            bead_id = BEAD_ID,
755            k_source,
756            symbol_size,
757            changeset_len = changeset_bytes.len(),
758            "single-shard changeset"
759        );
760        return Ok(vec![ChangesetShard {
761            changeset_bytes,
762            changeset_id: id,
763            seed,
764            k_source,
765        }]);
766    }
767
768    // Need to shard: split the changeset bytes into chunks
769    // Each chunk gets its own changeset_id and seed.
770    let max_chunk = u64::from(K_MAX) * t;
771    let n_shards = f.div_ceil(max_chunk);
772
773    info!(
774        bead_id = BEAD_ID,
775        n_shards,
776        k_source_total,
777        symbol_size,
778        changeset_len = changeset_bytes.len(),
779        "sharding large changeset"
780    );
781
782    let n_shards_usize = usize::try_from(n_shards).map_err(|_| FrankenError::OutOfRange {
783        what: "n_shards".to_owned(),
784        value: n_shards.to_string(),
785    })?;
786    let mut shards = Vec::with_capacity(n_shards_usize);
787    let max_chunk_usize = usize::try_from(max_chunk).map_err(|_| FrankenError::OutOfRange {
788        what: "max_chunk".to_owned(),
789        value: max_chunk.to_string(),
790    })?;
791
792    for (i, chunk) in changeset_bytes.chunks(max_chunk_usize).enumerate() {
793        let shard_bytes = chunk.to_vec();
794        let id = compute_changeset_id(&shard_bytes);
795        let seed = derive_seed_from_changeset_id(&id);
796        let k = compute_k_source(chunk.len(), symbol_size)?;
797        let k_source = u32::try_from(k).expect("each shard <= K_MAX symbols");
798
799        debug!(
800            bead_id = BEAD_ID,
801            shard_index = i,
802            k_source,
803            shard_len = chunk.len(),
804            "created changeset shard"
805        );
806
807        shards.push(ChangesetShard {
808            changeset_bytes: shard_bytes,
809            changeset_id: id,
810            seed,
811            k_source,
812        });
813    }
814
815    Ok(shards)
816}
817
818// ---------------------------------------------------------------------------
819// UDP Packet Format
820// ---------------------------------------------------------------------------
821
822/// Replication packet: big-endian header + little-endian symbol payload.
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct ReplicationPacket {
825    /// Packet framing format.
826    pub wire_version: ReplicationWireVersion,
827    /// 16-byte changeset identifier for multiplexing.
828    pub changeset_id: ChangesetId,
829    /// Source block number (MUST be 0 in V1).
830    pub sbn: u8,
831    /// Encoding Symbol ID (ISI).
832    pub esi: u32,
833    /// Number of source symbols.
834    pub k_source: u32,
835    /// Number of planned repair symbols for this stream configuration.
836    pub r_repair: u32,
837    /// Symbol size T encoded on wire.
838    pub symbol_size_t: u16,
839    /// Deterministic seed for the object's symbol schedule.
840    pub seed: u64,
841    /// Integrity hash over `symbol_data`.
842    pub payload_xxh3: u64,
843    /// Optional authenticated tag for security mode.
844    pub auth_tag: Option<[u8; 16]>,
845    /// Symbol data (T bytes).
846    pub symbol_data: Vec<u8>,
847}
848
849/// Packet framing versions for compatibility.
850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
851pub enum ReplicationWireVersion {
852    /// Legacy bd-1hi.13 packet layout (24-byte header).
853    LegacyV1,
854    /// Fixed-size versioned packet header with integrity/auth metadata.
855    FramedV2,
856}
857
858/// Metadata carried in a versioned V2 replication packet header.
859#[derive(Debug, Clone, Copy, PartialEq, Eq)]
860pub struct ReplicationPacketV2Header {
861    pub changeset_id: ChangesetId,
862    pub sbn: u8,
863    pub esi: u32,
864    pub k_source: u32,
865    pub r_repair: u32,
866    pub symbol_size_t: u16,
867    pub seed: u64,
868}
869
870impl ReplicationPacket {
871    /// Create a versioned fixed-header packet and compute payload integrity hash.
872    #[must_use]
873    pub fn new_v2(header: ReplicationPacketV2Header, symbol_data: Vec<u8>) -> Self {
874        let payload_xxh3 = Self::compute_payload_xxh3(&symbol_data);
875        Self {
876            wire_version: ReplicationWireVersion::FramedV2,
877            changeset_id: header.changeset_id,
878            sbn: header.sbn,
879            esi: header.esi,
880            k_source: header.k_source,
881            r_repair: header.r_repair,
882            symbol_size_t: header.symbol_size_t,
883            seed: header.seed,
884            payload_xxh3,
885            auth_tag: None,
886            symbol_data,
887        }
888    }
889
890    /// Compute packet payload hash.
891    #[must_use]
892    pub fn compute_payload_xxh3(symbol_data: &[u8]) -> u64 {
893        xxhash_rust::xxh3::xxh3_64(symbol_data)
894    }
895
896    fn auth_material(&self) -> Vec<u8> {
897        let mut material = Vec::with_capacity(16 + 1 + 4 + 4 + 4 + 2 + 8 + 8);
898        material.extend_from_slice(self.changeset_id.as_bytes());
899        material.push(self.sbn);
900        material.extend_from_slice(&self.esi.to_be_bytes());
901        material.extend_from_slice(&self.k_source.to_be_bytes());
902        material.extend_from_slice(&self.r_repair.to_be_bytes());
903        material.extend_from_slice(&self.symbol_size_t.to_be_bytes());
904        material.extend_from_slice(&self.seed.to_be_bytes());
905        material.extend_from_slice(&self.payload_xxh3.to_be_bytes());
906        material
907    }
908
909    fn compute_auth_tag(&self, auth_key: &[u8; 32]) -> [u8; 16] {
910        let mut hasher = blake3::Hasher::new_keyed(auth_key);
911        hasher.update(REPLICATION_PACKET_AUTH_DOMAIN.as_bytes());
912        hasher.update(&self.auth_material());
913        // Authentication must cover the bytes themselves, not only the
914        // non-cryptographic XXH3 transport checksum. Otherwise an XXH3
915        // collision would preserve the keyed tag.
916        hasher.update(&self.symbol_data);
917        let digest = hasher.finalize();
918        let mut out = [0_u8; 16];
919        out.copy_from_slice(&digest.as_bytes()[..16]);
920        out
921    }
922
923    /// Attach an auth tag for authenticated transport mode.
924    pub fn attach_auth_tag(&mut self, auth_key: &[u8; 32]) {
925        self.auth_tag = Some(self.compute_auth_tag(auth_key));
926    }
927
928    /// Verify payload hash and optional auth tag.
929    #[must_use]
930    pub fn verify_integrity(&self, auth_key: Option<&[u8; 32]>) -> bool {
931        if Self::compute_payload_xxh3(&self.symbol_data) != self.payload_xxh3 {
932            return false;
933        }
934        match (self.auth_tag, auth_key) {
935            (Some(tag), Some(key)) => auth_tags_equal(&tag, &self.compute_auth_tag(key)),
936            (Some(_), None) | (None, Some(_)) => false,
937            (None, None) => true,
938        }
939    }
940
941    /// Validate the symbol size against the hard wire limit.
942    ///
943    /// # Errors
944    ///
945    /// Returns error if symbol size exceeds `MAX_REPLICATION_SYMBOL_SIZE`.
946    pub fn validate_symbol_size(symbol_size: usize) -> Result<()> {
947        if symbol_size > MAX_REPLICATION_SYMBOL_SIZE {
948            error!(
949                bead_id = BEAD_ID,
950                symbol_size,
951                max = MAX_REPLICATION_SYMBOL_SIZE,
952                "symbol size exceeds UDP hard wire limit"
953            );
954            return Err(FrankenError::OutOfRange {
955                what: "symbol_size".to_owned(),
956                value: symbol_size.to_string(),
957            });
958        }
959        Ok(())
960    }
961
962    /// Encode to wire format: 24-byte big-endian header + symbol data.
963    ///
964    /// # Errors
965    ///
966    /// Returns error if ESI doesn't fit in 24 bits or symbol exceeds wire limit.
967    pub fn to_bytes(&self) -> Result<Vec<u8>> {
968        if self.esi > 0x00FF_FFFF {
969            return Err(FrankenError::OutOfRange {
970                what: "esi".to_owned(),
971                value: self.esi.to_string(),
972            });
973        }
974        if usize::from(self.symbol_size_t) != self.symbol_data.len() {
975            return Err(FrankenError::DatabaseCorrupt {
976                detail: format!(
977                    "symbol_size_t mismatch: header={}, payload={}",
978                    self.symbol_size_t,
979                    self.symbol_data.len()
980                ),
981            });
982        }
983        let computed_xxh3 = Self::compute_payload_xxh3(&self.symbol_data);
984        if computed_xxh3 != self.payload_xxh3 {
985            return Err(FrankenError::DatabaseCorrupt {
986                detail: format!(
987                    "payload_xxh3 mismatch before encoding: header={:#x}, payload={:#x}",
988                    self.payload_xxh3, computed_xxh3
989                ),
990            });
991        }
992        Self::validate_symbol_size(self.symbol_data.len())?;
993
994        match self.wire_version {
995            ReplicationWireVersion::LegacyV1 => {
996                let total = REPLICATION_HEADER_SIZE_LEGACY + self.symbol_data.len();
997                let mut buf = Vec::with_capacity(total);
998                buf.extend_from_slice(self.changeset_id.as_bytes());
999                buf.push(self.sbn);
1000                let esi_bytes = self.esi.to_be_bytes();
1001                buf.extend_from_slice(&esi_bytes[1..4]);
1002                buf.extend_from_slice(&self.k_source.to_be_bytes());
1003                buf.extend_from_slice(&self.symbol_data);
1004                Ok(buf)
1005            }
1006            ReplicationWireVersion::FramedV2 => {
1007                let total = REPLICATION_HEADER_SIZE + self.symbol_data.len();
1008                let mut buf = Vec::with_capacity(total);
1009                let mut flags = 0_u8;
1010                if self.auth_tag.is_some() {
1011                    flags |= REPLICATION_FLAG_AUTH_PRESENT;
1012                }
1013                buf.extend_from_slice(&REPLICATION_PROTOCOL_MAGIC);
1014                buf.push(REPLICATION_PROTOCOL_VERSION_V2);
1015                buf.push(flags);
1016                buf.extend_from_slice(&REPLICATION_HEADER_SIZE_V2_U16.to_be_bytes());
1017                buf.extend_from_slice(self.changeset_id.as_bytes());
1018                buf.push(self.sbn);
1019                let esi_bytes = self.esi.to_be_bytes();
1020                buf.extend_from_slice(&esi_bytes[1..4]);
1021                buf.extend_from_slice(&self.k_source.to_be_bytes());
1022                buf.extend_from_slice(&self.r_repair.to_be_bytes());
1023                buf.extend_from_slice(&self.symbol_size_t.to_be_bytes());
1024                buf.extend_from_slice(&0_u16.to_be_bytes()); // reserved
1025                buf.extend_from_slice(&self.seed.to_be_bytes());
1026                buf.extend_from_slice(&self.payload_xxh3.to_be_bytes());
1027                if let Some(tag) = self.auth_tag {
1028                    buf.extend_from_slice(&tag);
1029                } else {
1030                    buf.extend_from_slice(&[0_u8; 16]);
1031                }
1032                buf.extend_from_slice(&self.symbol_data);
1033                Ok(buf)
1034            }
1035        }
1036    }
1037
1038    /// Decode from wire format.
1039    ///
1040    /// # Errors
1041    ///
1042    /// Returns error if buffer is too short.
1043    pub fn from_bytes(buf: &[u8]) -> Result<Self> {
1044        if buf.len() < REPLICATION_HEADER_SIZE_LEGACY {
1045            return Err(FrankenError::DatabaseCorrupt {
1046                detail: format!(
1047                    "replication packet too short: {} < {REPLICATION_HEADER_SIZE_LEGACY}",
1048                    buf.len()
1049                ),
1050            });
1051        }
1052        let is_v2 = buf.len() >= REPLICATION_HEADER_SIZE
1053            && buf[0..4] == REPLICATION_PROTOCOL_MAGIC
1054            && buf[4] == REPLICATION_PROTOCOL_VERSION_V2;
1055        if is_v2 {
1056            let flags = buf[5];
1057            let unsupported_flags = flags & !REPLICATION_FLAG_AUTH_PRESENT;
1058            if unsupported_flags != 0 {
1059                return Err(FrankenError::DatabaseCorrupt {
1060                    detail: format!(
1061                        "unsupported replication packet flags: {unsupported_flags:#04x}"
1062                    ),
1063                });
1064            }
1065            let header_len = usize::from(u16::from_be_bytes([buf[6], buf[7]]));
1066            if header_len != REPLICATION_HEADER_SIZE {
1067                return Err(FrankenError::DatabaseCorrupt {
1068                    detail: format!(
1069                        "unsupported replication header length: expected {}, got {header_len}",
1070                        REPLICATION_HEADER_SIZE
1071                    ),
1072                });
1073            }
1074            if buf.len() < header_len {
1075                return Err(FrankenError::DatabaseCorrupt {
1076                    detail: format!("packet shorter than declared header length: {header_len}"),
1077                });
1078            }
1079            let mut id_bytes = [0_u8; 16];
1080            id_bytes.copy_from_slice(&buf[8..24]);
1081            let changeset_id = ChangesetId::from_bytes(id_bytes);
1082            let sbn = buf[24];
1083            let esi = u32::from(buf[25]) << 16 | u32::from(buf[26]) << 8 | u32::from(buf[27]);
1084            let k_source = u32::from_be_bytes(buf[28..32].try_into().expect("4 bytes"));
1085            let r_repair = u32::from_be_bytes(buf[32..36].try_into().expect("4 bytes"));
1086            let symbol_size_t = u16::from_be_bytes(buf[36..38].try_into().expect("2 bytes"));
1087            if buf[38] != 0 || buf[39] != 0 {
1088                return Err(FrankenError::DatabaseCorrupt {
1089                    detail: "replication packet reserved bytes must be zero".to_owned(),
1090                });
1091            }
1092            let seed = u64::from_be_bytes(buf[40..48].try_into().expect("8 bytes"));
1093            let payload_xxh3 = u64::from_be_bytes(buf[48..56].try_into().expect("8 bytes"));
1094            let mut auth_tag_bytes = [0_u8; 16];
1095            auth_tag_bytes.copy_from_slice(&buf[56..72]);
1096            let auth_tag = if (flags & REPLICATION_FLAG_AUTH_PRESENT) != 0 {
1097                Some(auth_tag_bytes)
1098            } else {
1099                None
1100            };
1101            let symbol_data = buf[header_len..].to_vec();
1102            if symbol_data.len() != usize::from(symbol_size_t) {
1103                return Err(FrankenError::DatabaseCorrupt {
1104                    detail: format!(
1105                        "symbol_size_t mismatch in packet: header={symbol_size_t}, payload={}",
1106                        symbol_data.len()
1107                    ),
1108                });
1109            }
1110            return Ok(Self {
1111                wire_version: ReplicationWireVersion::FramedV2,
1112                changeset_id,
1113                sbn,
1114                esi,
1115                k_source,
1116                r_repair,
1117                symbol_size_t,
1118                seed,
1119                payload_xxh3,
1120                auth_tag,
1121                symbol_data,
1122            });
1123        }
1124
1125        let mut id_bytes = [0_u8; 16];
1126        id_bytes.copy_from_slice(&buf[0..16]);
1127        let changeset_id = ChangesetId::from_bytes(id_bytes);
1128        let sbn = buf[16];
1129        let esi = u32::from(buf[17]) << 16 | u32::from(buf[18]) << 8 | u32::from(buf[19]);
1130        let k_source = u32::from_be_bytes(buf[20..24].try_into().expect("4 bytes"));
1131        let symbol_data = buf[24..].to_vec();
1132        let symbol_size_t =
1133            u16::try_from(symbol_data.len()).map_err(|_| FrankenError::OutOfRange {
1134                what: "symbol_size_t".to_owned(),
1135                value: symbol_data.len().to_string(),
1136            })?;
1137
1138        Ok(Self {
1139            wire_version: ReplicationWireVersion::LegacyV1,
1140            changeset_id,
1141            sbn,
1142            esi,
1143            k_source,
1144            r_repair: 0,
1145            symbol_size_t,
1146            seed: derive_seed_from_changeset_id(&changeset_id),
1147            payload_xxh3: Self::compute_payload_xxh3(&symbol_data),
1148            auth_tag: None,
1149            symbol_data,
1150        })
1151    }
1152
1153    /// Total packet size on the wire.
1154    #[must_use]
1155    pub fn wire_size(&self) -> usize {
1156        let header_size = match self.wire_version {
1157            ReplicationWireVersion::LegacyV1 => REPLICATION_HEADER_SIZE_LEGACY,
1158            ReplicationWireVersion::FramedV2 => REPLICATION_HEADER_SIZE,
1159        };
1160        header_size + self.symbol_data.len()
1161    }
1162
1163    /// Whether this packet carries a source symbol (systematic).
1164    #[must_use]
1165    pub fn is_source_symbol(&self) -> bool {
1166        self.esi < self.k_source
1167    }
1168}
1169
1170// ---------------------------------------------------------------------------
1171// Sender State Machine
1172// ---------------------------------------------------------------------------
1173
1174/// Sender state (§3.4.2).
1175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1176pub enum SenderState {
1177    /// No active replication session.
1178    Idle,
1179    /// Changeset encoded, encoder prepared.
1180    Encoding,
1181    /// Streaming symbols to receiver(s).
1182    Streaming,
1183    /// Streaming complete, resources released.
1184    Complete,
1185}
1186
1187/// Configuration for the replication sender.
1188#[derive(Debug, Clone)]
1189pub struct SenderConfig {
1190    /// Symbol size for replication transport.
1191    pub symbol_size: u16,
1192    /// Maximum ISI = `max_isi_multiplier * k_source`.
1193    pub max_isi_multiplier: u32,
1194}
1195
1196impl Default for SenderConfig {
1197    fn default() -> Self {
1198        Self {
1199            symbol_size: MTU_SAFE_SYMBOL_SIZE,
1200            max_isi_multiplier: DEFAULT_MAX_ISI_MULTIPLIER,
1201        }
1202    }
1203}
1204
1205/// Prepared encoding session ready for streaming.
1206#[derive(Debug)]
1207pub struct EncodingSession {
1208    /// Shards of the changeset.
1209    pub shards: Vec<ChangesetShard>,
1210    /// Current shard index being streamed.
1211    pub current_shard: usize,
1212    /// Current ISI within the current shard.
1213    pub current_isi: u32,
1214    /// Configuration.
1215    pub config: SenderConfig,
1216}
1217
1218/// Replication sender state machine.
1219#[derive(Debug)]
1220pub struct ReplicationSender {
1221    state: SenderState,
1222    session: Option<EncodingSession>,
1223}
1224
1225impl ReplicationSender {
1226    /// Create a new sender in IDLE state.
1227    #[must_use]
1228    pub fn new() -> Self {
1229        Self {
1230            state: SenderState::Idle,
1231            session: None,
1232        }
1233    }
1234
1235    /// Current state.
1236    #[must_use]
1237    pub const fn state(&self) -> SenderState {
1238        self.state
1239    }
1240
1241    /// Transition from IDLE to ENCODING: prepare a changeset for streaming.
1242    ///
1243    /// # Errors
1244    ///
1245    /// Returns error if not in IDLE state, pages are empty, or symbol size invalid.
1246    pub fn prepare(
1247        &mut self,
1248        page_size: u32,
1249        pages: &mut [PageEntry],
1250        config: SenderConfig,
1251    ) -> Result<()> {
1252        if self.state != SenderState::Idle {
1253            return Err(FrankenError::Internal(format!(
1254                "sender must be IDLE to prepare, current state: {:?}",
1255                self.state
1256            )));
1257        }
1258
1259        ReplicationPacket::validate_symbol_size(usize::from(config.symbol_size))?;
1260
1261        let changeset_bytes = encode_changeset(page_size, pages)?;
1262        let shards = shard_changeset(changeset_bytes, config.symbol_size)?;
1263
1264        info!(
1265            bead_id = BEAD_ID,
1266            n_shards = shards.len(),
1267            symbol_size = config.symbol_size,
1268            "sender prepared for streaming"
1269        );
1270
1271        self.session = Some(EncodingSession {
1272            shards,
1273            current_shard: 0,
1274            current_isi: 0,
1275            config,
1276        });
1277        self.state = SenderState::Encoding;
1278        Ok(())
1279    }
1280
1281    /// Transition from ENCODING to STREAMING.
1282    ///
1283    /// # Errors
1284    ///
1285    /// Returns error if not in ENCODING state.
1286    pub fn start_streaming(&mut self) -> Result<()> {
1287        if self.state != SenderState::Encoding {
1288            return Err(FrankenError::Internal(format!(
1289                "sender must be ENCODING to start streaming, current state: {:?}",
1290                self.state
1291            )));
1292        }
1293        self.state = SenderState::Streaming;
1294        info!(bead_id = BEAD_ID, "sender started streaming");
1295        Ok(())
1296    }
1297
1298    /// Generate the next replication packet in the stream.
1299    ///
1300    /// Returns `None` when all shards have been fully streamed (ISI limit reached).
1301    ///
1302    /// # Errors
1303    ///
1304    /// Returns error if not in STREAMING state.
1305    #[allow(clippy::too_many_lines)]
1306    pub fn next_packet(&mut self) -> Result<Option<ReplicationPacket>> {
1307        if self.state != SenderState::Streaming {
1308            return Err(FrankenError::Internal(format!(
1309                "sender must be STREAMING to generate packets, current state: {:?}",
1310                self.state
1311            )));
1312        }
1313
1314        let session = self
1315            .session
1316            .as_mut()
1317            .expect("session exists in STREAMING state");
1318
1319        if session.current_shard >= session.shards.len() {
1320            // All shards complete.
1321            return Ok(None);
1322        }
1323
1324        let shard = &session.shards[session.current_shard];
1325        let max_isi = shard
1326            .k_source
1327            .saturating_mul(session.config.max_isi_multiplier);
1328
1329        if session.current_isi >= max_isi {
1330            // Move to next shard.
1331            session.current_shard += 1;
1332            session.current_isi = 0;
1333
1334            if session.current_shard >= session.shards.len() {
1335                return Ok(None);
1336            }
1337
1338            let next_shard = &session.shards[session.current_shard];
1339            debug!(
1340                bead_id = BEAD_ID,
1341                shard_index = session.current_shard,
1342                k_source = next_shard.k_source,
1343                "advancing to next shard"
1344            );
1345        }
1346
1347        let shard = &session.shards[session.current_shard];
1348        let isi = session.current_isi;
1349        let t = usize::from(session.config.symbol_size);
1350
1351        // Generate symbol data for current ISI.
1352        // For source symbols (ISI < K_source): extract from changeset bytes.
1353        // For repair symbols (ISI >= K_source): would use RaptorQ encoder in production.
1354        // Here we provide the framework; actual FEC encoding is delegated to asupersync.
1355        let symbol_data = if u64::from(isi) < u64::from(shard.k_source) {
1356            // Source symbol: extract T bytes starting at ISI * T.
1357            let start = isi as usize * t;
1358            let end = (start + t).min(shard.changeset_bytes.len());
1359            let mut data = vec![0_u8; t];
1360            let available = end.saturating_sub(start);
1361            if available > 0 {
1362                data[..available].copy_from_slice(&shard.changeset_bytes[start..end]);
1363            }
1364            // Remaining bytes are zero-padded (per RFC 6330 symbol alignment).
1365            data
1366        } else {
1367            #[cfg(not(target_arch = "wasm32"))]
1368            {
1369                // Repair symbol: use asupersync's RaptorQ SystematicEncoder.
1370                //
1371                // IMPORTANT: The encoder is rebuilt for each repair symbol call.
1372                // SystematicEncoder::new() solves a constraint matrix, which is
1373                // O(K^2) or worse. For production use with many repair symbols per
1374                // shard, the encoder should be cached in EncodingSession (requires
1375                // making EncodingSession non-Debug or wrapping the encoder).
1376                // This is correct but slow for large K_source values.
1377                use asupersync::raptorq::systematic::SystematicEncoder;
1378
1379                let source_symbols: Vec<Vec<u8>> = (0..shard.k_source as usize)
1380                    .map(|i| {
1381                        let start = i * t;
1382                        let end = (start + t).min(shard.changeset_bytes.len());
1383                        let mut sym = vec![0_u8; t];
1384                        let available = end.saturating_sub(start);
1385                        if available > 0 {
1386                            sym[..available].copy_from_slice(&shard.changeset_bytes[start..end]);
1387                        }
1388                        sym
1389                    })
1390                    .collect();
1391
1392                match SystematicEncoder::new(&source_symbols, t, shard.seed) {
1393                    Some(encoder) => encoder.repair_symbol(isi),
1394                    None => {
1395                        warn!(
1396                            bead_id = BEAD_ID,
1397                            isi,
1398                            shard_index = session.current_shard,
1399                            "RaptorQ encoder construction failed; using placeholder repair symbol"
1400                        );
1401                        generate_deterministic_placeholder(shard.seed, isi, t)
1402                    }
1403                }
1404            }
1405            #[cfg(target_arch = "wasm32")]
1406            {
1407                warn!(
1408                    bead_id = BEAD_ID,
1409                    isi,
1410                    shard_index = session.current_shard,
1411                    "RaptorQ encoder is native-only; using placeholder repair symbol"
1412                );
1413                generate_deterministic_placeholder(shard.seed, isi, t)
1414            }
1415        };
1416
1417        let r_repair = max_isi.saturating_sub(shard.k_source);
1418        let packet = ReplicationPacket::new_v2(
1419            ReplicationPacketV2Header {
1420                changeset_id: shard.changeset_id,
1421                sbn: 0, // V1/V2 single-source-block path
1422                esi: isi,
1423                k_source: shard.k_source,
1424                r_repair,
1425                symbol_size_t: session.config.symbol_size,
1426                seed: shard.seed,
1427            },
1428            symbol_data,
1429        );
1430
1431        session.current_isi += 1;
1432        Ok(Some(packet))
1433    }
1434
1435    /// Acknowledge completion from receiver: stop streaming and transition to COMPLETE.
1436    ///
1437    /// # Errors
1438    ///
1439    /// Returns error if not in STREAMING state.
1440    pub fn acknowledge_complete(&mut self) -> Result<()> {
1441        if self.state != SenderState::Streaming {
1442            return Err(FrankenError::Internal(format!(
1443                "sender must be STREAMING to acknowledge, current state: {:?}",
1444                self.state
1445            )));
1446        }
1447        self.state = SenderState::Complete;
1448        info!(bead_id = BEAD_ID, "sender acknowledged completion");
1449        Ok(())
1450    }
1451
1452    /// Complete streaming: release resources and transition to COMPLETE.
1453    ///
1454    /// This is called when ISI limit is reached or explicit stop.
1455    pub fn complete(&mut self) {
1456        if self.state == SenderState::Streaming || self.state == SenderState::Encoding {
1457            self.state = SenderState::Complete;
1458            info!(bead_id = BEAD_ID, "sender completed");
1459        }
1460    }
1461
1462    /// Reset to IDLE for the next replication session.
1463    pub fn reset(&mut self) {
1464        self.state = SenderState::Idle;
1465        self.session = None;
1466        debug!(bead_id = BEAD_ID, "sender reset to IDLE");
1467    }
1468}
1469
1470impl Default for ReplicationSender {
1471    fn default() -> Self {
1472        Self::new()
1473    }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479
1480    const TEST_BEAD_ID: &str = "bd-1hi.13";
1481    const TEST_BEAD_BD_1SQU: &str = "bd-1squ";
1482
1483    #[allow(clippy::cast_possible_truncation)]
1484    fn make_pages(page_size: u32, page_numbers: &[u32]) -> Vec<PageEntry> {
1485        page_numbers
1486            .iter()
1487            .map(|&pn| {
1488                let mut data = vec![0_u8; page_size as usize];
1489                // Fill with deterministic data based on page number.
1490                for (i, byte) in data.iter_mut().enumerate() {
1491                    *byte = ((pn as usize * 251 + i * 31) % 256) as u8;
1492                }
1493                PageEntry::new(pn, data)
1494            })
1495            .collect()
1496    }
1497
1498    // -----------------------------------------------------------------------
1499    // Changeset encoding tests
1500    // -----------------------------------------------------------------------
1501
1502    #[test]
1503    fn test_changeset_header_format() {
1504        let header = ChangesetHeader {
1505            magic: CHANGESET_MAGIC,
1506            version: CHANGESET_VERSION,
1507            page_size: 4096,
1508            n_pages: 10,
1509            total_len: 42_000,
1510        };
1511        let bytes = header.to_bytes();
1512        assert_eq!(
1513            &bytes[0..4],
1514            b"FSRP",
1515            "bead_id={TEST_BEAD_ID} case=header_magic"
1516        );
1517        assert_eq!(bytes.len(), CHANGESET_HEADER_SIZE);
1518
1519        let decoded = ChangesetHeader::from_bytes(&bytes).expect("decode should succeed");
1520        assert_eq!(
1521            header, decoded,
1522            "bead_id={TEST_BEAD_ID} case=header_roundtrip"
1523        );
1524    }
1525
1526    #[test]
1527    fn test_changeset_encoding_deterministic() {
1528        let page_size = 512_u32;
1529        let mut pages_a = make_pages(page_size, &[3, 1, 2]);
1530        let mut pages_b = make_pages(page_size, &[2, 3, 1]); // different order
1531
1532        let bytes_a = encode_changeset(page_size, &mut pages_a).expect("encode a");
1533        let bytes_b = encode_changeset(page_size, &mut pages_b).expect("encode b");
1534
1535        // Same pages (different input order) → same changeset bytes (sorted).
1536        assert_eq!(
1537            bytes_a, bytes_b,
1538            "bead_id={TEST_BEAD_ID} case=deterministic_encoding"
1539        );
1540
1541        // Same bytes → same changeset_id.
1542        let id_a = compute_changeset_id(&bytes_a);
1543        let id_b = compute_changeset_id(&bytes_b);
1544        assert_eq!(
1545            id_a, id_b,
1546            "bead_id={TEST_BEAD_ID} case=deterministic_changeset_id"
1547        );
1548    }
1549
1550    #[test]
1551    fn test_changeset_id_domain_separation() {
1552        let data = b"test payload";
1553
1554        // Changeset domain
1555        let changeset_id = compute_changeset_id(data);
1556
1557        // Different domain (simulating ECS)
1558        let mut hasher = blake3::Hasher::new();
1559        hasher.update(b"fsqlite:ecs:v1");
1560        hasher.update(data);
1561        let ecs_hash = hasher.finalize();
1562        let mut ecs_id = [0_u8; 16];
1563        ecs_id.copy_from_slice(&ecs_hash.as_bytes()[..16]);
1564
1565        assert_ne!(
1566            changeset_id.as_bytes(),
1567            &ecs_id,
1568            "bead_id={TEST_BEAD_ID} case=domain_separation"
1569        );
1570    }
1571
1572    #[test]
1573    fn test_seed_derivation() {
1574        let id = ChangesetId::from_bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
1575        let seed = derive_seed_from_changeset_id(&id);
1576
1577        // Deterministic: same id → same seed.
1578        let seed2 = derive_seed_from_changeset_id(&id);
1579        assert_eq!(
1580            seed, seed2,
1581            "bead_id={TEST_BEAD_ID} case=seed_deterministic"
1582        );
1583
1584        // Non-trivial.
1585        assert_ne!(seed, 0, "bead_id={TEST_BEAD_ID} case=seed_nonzero");
1586    }
1587
1588    #[test]
1589    fn test_bd_1squ_changeset_id_stability() {
1590        let payload = b"deterministic-changeset-payload";
1591        let id_a = compute_changeset_id(payload);
1592        let id_b = compute_changeset_id(payload);
1593        assert_eq!(
1594            id_a, id_b,
1595            "bead_id={TEST_BEAD_BD_1SQU} case=id_stability_same_payload"
1596        );
1597
1598        let mut altered = payload.to_vec();
1599        altered[0] ^= 0xFF;
1600        let id_c = compute_changeset_id(&altered);
1601        assert_ne!(
1602            id_a, id_c,
1603            "bead_id={TEST_BEAD_BD_1SQU} case=id_stability_diff_payload"
1604        );
1605    }
1606
1607    #[test]
1608    fn test_bd_1squ_seed_stability() {
1609        let id = compute_changeset_id(b"seed-stability");
1610        let seed_a = derive_seed_from_changeset_id(&id);
1611        let seed_b = derive_seed_from_changeset_id(&id);
1612        assert_eq!(
1613            seed_a, seed_b,
1614            "bead_id={TEST_BEAD_BD_1SQU} case=seed_stability_same_id"
1615        );
1616
1617        let other = compute_changeset_id(b"seed-stability-other");
1618        let seed_other = derive_seed_from_changeset_id(&other);
1619        assert_ne!(
1620            seed_a, seed_other,
1621            "bead_id={TEST_BEAD_BD_1SQU} case=seed_stability_diff_id"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_bd_1squ_k_source_computation() {
1627        assert_eq!(
1628            compute_k_source(0, 256).expect("k_source"),
1629            0,
1630            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_empty"
1631        );
1632        assert_eq!(
1633            compute_k_source(1, 256).expect("k_source"),
1634            1,
1635            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_single_byte"
1636        );
1637        assert_eq!(
1638            compute_k_source(256, 256).expect("k_source"),
1639            1,
1640            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_exact_division"
1641        );
1642        assert_eq!(
1643            compute_k_source(257, 256).expect("k_source"),
1644            2,
1645            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_round_up"
1646        );
1647        assert_eq!(
1648            compute_k_source(usize::try_from(K_MAX).unwrap() * 64, 64).expect("k_source"),
1649            u64::from(K_MAX),
1650            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_kmax_boundary"
1651        );
1652        assert_eq!(
1653            compute_k_source(usize::try_from(K_MAX).unwrap() * 64 + 1, 64).expect("k_source"),
1654            u64::from(K_MAX) + 1,
1655            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_kmax_plus_one"
1656        );
1657        assert!(
1658            compute_k_source(10, 0).is_err(),
1659            "bead_id={TEST_BEAD_BD_1SQU} case=k_source_zero_symbol_rejected"
1660        );
1661    }
1662
1663    #[test]
1664    fn test_bd_1squ_sharding_threshold_rule() {
1665        let symbol_size = 64_u16;
1666        let max_payload = usize::try_from(u64::from(K_MAX) * u64::from(symbol_size)).unwrap();
1667
1668        let exact = vec![0xA5_u8; max_payload];
1669        let exact_shards = shard_changeset(exact, symbol_size).expect("exact shard");
1670        assert_eq!(
1671            exact_shards.len(),
1672            1,
1673            "bead_id={TEST_BEAD_BD_1SQU} case=exact_threshold_single_shard"
1674        );
1675        assert_eq!(
1676            exact_shards[0].k_source, K_MAX,
1677            "bead_id={TEST_BEAD_BD_1SQU} case=exact_threshold_kmax"
1678        );
1679
1680        let over = vec![0x5A_u8; max_payload + 1];
1681        let over_shards = shard_changeset(over, symbol_size).expect("over shard");
1682        assert_eq!(
1683            over_shards.len(),
1684            2,
1685            "bead_id={TEST_BEAD_BD_1SQU} case=over_threshold_two_shards"
1686        );
1687        assert_eq!(
1688            over_shards[0].k_source, K_MAX,
1689            "bead_id={TEST_BEAD_BD_1SQU} case=over_threshold_first_kmax"
1690        );
1691        assert_eq!(
1692            over_shards[1].k_source, 1,
1693            "bead_id={TEST_BEAD_BD_1SQU} case=over_threshold_second_one_symbol"
1694        );
1695    }
1696
1697    #[test]
1698    fn test_page_entries_sorted() {
1699        let page_size = 128_u32;
1700        let mut pages = make_pages(page_size, &[5, 1, 3, 2, 4]);
1701        let bytes = encode_changeset(page_size, &mut pages).expect("encode");
1702
1703        // Verify pages are sorted in the output.
1704        assert_eq!(pages[0].page_number, 1);
1705        assert_eq!(pages[1].page_number, 2);
1706        assert_eq!(pages[2].page_number, 3);
1707        assert_eq!(pages[3].page_number, 4);
1708        assert_eq!(pages[4].page_number, 5);
1709
1710        // Verify total_len from header matches actual length.
1711        let header_bytes: [u8; CHANGESET_HEADER_SIZE] =
1712            bytes[..CHANGESET_HEADER_SIZE].try_into().unwrap();
1713        let header = ChangesetHeader::from_bytes(&header_bytes).expect("decode header");
1714        assert_eq!(
1715            header.total_len,
1716            bytes.len() as u64,
1717            "bead_id={TEST_BEAD_ID} case=total_len_matches"
1718        );
1719        assert_eq!(header.n_pages, 5);
1720    }
1721
1722    #[test]
1723    fn test_page_xxh3_validation() {
1724        let page = PageEntry::new(1, vec![0xAA; 4096]);
1725        assert!(
1726            page.validate_xxh3(),
1727            "bead_id={TEST_BEAD_ID} case=xxh3_valid"
1728        );
1729
1730        // Tampered page fails validation.
1731        let mut tampered = page;
1732        tampered.page_bytes[0] ^= 0xFF;
1733        assert!(
1734            !tampered.validate_xxh3(),
1735            "bead_id={TEST_BEAD_ID} case=xxh3_tampered"
1736        );
1737    }
1738
1739    #[test]
1740    fn test_encode_changeset_rejects_page_size_mismatch() {
1741        let mut pages = vec![PageEntry::new(1, vec![0xAA; 127])];
1742        let result = encode_changeset(128, &mut pages);
1743        assert!(
1744            matches!(result, Err(FrankenError::OutOfRange { .. })),
1745            "bead_id={TEST_BEAD_ID} case=page_size_mismatch_rejected"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_encode_changeset_rejects_stale_page_checksum() {
1751        let page_size = 128_u32;
1752        let mut pages = make_pages(page_size, &[1]);
1753        pages[0].page_bytes[0] ^= 0xFF;
1754
1755        let result = encode_changeset(page_size, &mut pages);
1756        assert!(
1757            matches!(result, Err(FrankenError::DatabaseCorrupt { .. })),
1758            "bead_id={TEST_BEAD_ID} case=stale_page_checksum_rejected"
1759        );
1760    }
1761
1762    // -----------------------------------------------------------------------
1763    // UDP Packet format tests
1764    // -----------------------------------------------------------------------
1765
1766    #[test]
1767    fn test_udp_packet_format() {
1768        let id = ChangesetId::from_bytes([0xAA; 16]);
1769        let packet = ReplicationPacket::new_v2(
1770            ReplicationPacketV2Header {
1771                changeset_id: id,
1772                sbn: 0,
1773                esi: 42,
1774                k_source: 100,
1775                r_repair: 12,
1776                symbol_size_t: 512,
1777                seed: derive_seed_from_changeset_id(&id),
1778            },
1779            vec![0x55; 512],
1780        );
1781
1782        let wire = packet.to_bytes().expect("encode");
1783        assert_eq!(
1784            wire.len(),
1785            REPLICATION_HEADER_SIZE + 512,
1786            "bead_id={TEST_BEAD_ID} case=packet_size"
1787        );
1788
1789        // Header is versioned and fixed-size.
1790        assert_eq!(&wire[0..4], &REPLICATION_PROTOCOL_MAGIC);
1791        assert_eq!(wire[4], REPLICATION_PROTOCOL_VERSION_V2);
1792        assert_eq!(wire[5], 0, "flags");
1793        assert_eq!(&wire[8..24], &[0xAA; 16], "changeset_id");
1794        assert_eq!(wire[24], 0, "sbn");
1795        assert_eq!(&wire[25..28], &[0, 0, 42], "esi u24 big-endian");
1796        assert_eq!(&wire[28..32], &100_u32.to_be_bytes(), "k_source");
1797        assert_eq!(&wire[32..36], &12_u32.to_be_bytes(), "r_repair");
1798        assert_eq!(&wire[36..38], &512_u16.to_be_bytes(), "symbol_size_t");
1799
1800        // Roundtrip.
1801        let decoded = ReplicationPacket::from_bytes(&wire).expect("decode");
1802        assert_eq!(
1803            packet, decoded,
1804            "bead_id={TEST_BEAD_ID} case=packet_roundtrip"
1805        );
1806    }
1807
1808    #[test]
1809    fn test_v2_packet_rejects_unknown_flags_and_reserved_bytes() {
1810        let id = ChangesetId::from_bytes([0xAB; 16]);
1811        let packet = ReplicationPacket::new_v2(
1812            ReplicationPacketV2Header {
1813                changeset_id: id,
1814                sbn: 0,
1815                esi: 7,
1816                k_source: 9,
1817                r_repair: 1,
1818                symbol_size_t: 16,
1819                seed: derive_seed_from_changeset_id(&id),
1820            },
1821            vec![0x11; 16],
1822        );
1823
1824        let mut unknown_flags = packet.to_bytes().expect("encode");
1825        unknown_flags[5] |= 0b1000_0000;
1826        assert!(
1827            matches!(
1828                ReplicationPacket::from_bytes(&unknown_flags),
1829                Err(FrankenError::DatabaseCorrupt { .. })
1830            ),
1831            "bead_id={TEST_BEAD_ID} case=unknown_flags_rejected"
1832        );
1833
1834        let mut nonzero_reserved = packet.to_bytes().expect("encode");
1835        nonzero_reserved[38] = 1;
1836        assert!(
1837            matches!(
1838                ReplicationPacket::from_bytes(&nonzero_reserved),
1839                Err(FrankenError::DatabaseCorrupt { .. })
1840            ),
1841            "bead_id={TEST_BEAD_ID} case=reserved_bytes_rejected"
1842        );
1843    }
1844
1845    #[test]
1846    fn test_auth_tag_covers_symbol_bytes_not_only_xxh3() {
1847        let key = [0x42; 32];
1848        let id = ChangesetId::from_bytes([0xCD; 16]);
1849        let packet = ReplicationPacket::new_v2(
1850            ReplicationPacketV2Header {
1851                changeset_id: id,
1852                sbn: 0,
1853                esi: 1,
1854                k_source: 2,
1855                r_repair: 0,
1856                symbol_size_t: 8,
1857                seed: derive_seed_from_changeset_id(&id),
1858            },
1859            vec![0x55; 8],
1860        );
1861        let mut altered = packet.clone();
1862        altered.symbol_data[0] ^= 0xFF;
1863        altered.payload_xxh3 = packet.payload_xxh3;
1864
1865        assert_ne!(
1866            packet.compute_auth_tag(&key),
1867            altered.compute_auth_tag(&key),
1868            "bead_id={TEST_BEAD_ID} case=auth_tag_covers_payload_bytes"
1869        );
1870    }
1871
1872    #[test]
1873    fn test_udp_packet_mtu_safe() {
1874        // T=1400 → packet 1472 bytes. With IP(20) + UDP(8) = 1500 = Ethernet MTU.
1875        let t = usize::from(MTU_SAFE_SYMBOL_SIZE);
1876        let total = REPLICATION_HEADER_SIZE + t;
1877        assert_eq!(
1878            total, 1472,
1879            "bead_id={TEST_BEAD_ID} case=mtu_safe_packet_size"
1880        );
1881        // Plus IP + UDP headers: 1472 + 20 + 8 = 1500.
1882        assert_eq!(total + 20 + 8, 1500, "fits in Ethernet MTU");
1883    }
1884
1885    #[test]
1886    fn test_hard_wire_limit() {
1887        // Symbol that exceeds the hard wire limit.
1888        let oversized = MAX_REPLICATION_SYMBOL_SIZE + 1;
1889        let result = ReplicationPacket::validate_symbol_size(oversized);
1890        assert!(
1891            result.is_err(),
1892            "bead_id={TEST_BEAD_ID} case=hard_wire_limit_rejected"
1893        );
1894
1895        // At the limit: OK.
1896        let at_limit = MAX_REPLICATION_SYMBOL_SIZE;
1897        let result = ReplicationPacket::validate_symbol_size(at_limit);
1898        assert!(
1899            result.is_ok(),
1900            "bead_id={TEST_BEAD_ID} case=hard_wire_limit_at_max"
1901        );
1902    }
1903
1904    // -----------------------------------------------------------------------
1905    // State machine tests
1906    // -----------------------------------------------------------------------
1907
1908    #[test]
1909    fn test_sender_idle_to_encoding() {
1910        let mut sender = ReplicationSender::new();
1911        assert_eq!(sender.state(), SenderState::Idle);
1912
1913        let mut pages = make_pages(512, &[1, 2, 3]);
1914        sender
1915            .prepare(512, &mut pages, SenderConfig::default())
1916            .expect("prepare");
1917        assert_eq!(
1918            sender.state(),
1919            SenderState::Encoding,
1920            "bead_id={TEST_BEAD_ID} case=idle_to_encoding"
1921        );
1922    }
1923
1924    #[test]
1925    fn test_streaming_source_then_repair() {
1926        let mut sender = ReplicationSender::new();
1927        let mut pages = make_pages(512, &[1, 2]);
1928        let config = SenderConfig {
1929            symbol_size: 512,
1930            max_isi_multiplier: 2,
1931        };
1932        sender.prepare(512, &mut pages, config).expect("prepare");
1933        sender.start_streaming().expect("start");
1934
1935        let session = sender.session.as_ref().unwrap();
1936        let k_source = session.shards[0].k_source;
1937
1938        let mut source_count = 0_u32;
1939        let mut repair_count = 0_u32;
1940        let mut last_isi = 0_u32;
1941
1942        while let Some(packet) = sender.next_packet().expect("next") {
1943            if packet.is_source_symbol() {
1944                source_count += 1;
1945            } else {
1946                repair_count += 1;
1947            }
1948            last_isi = packet.esi;
1949        }
1950
1951        assert!(
1952            source_count > 0,
1953            "bead_id={TEST_BEAD_ID} case=has_source_symbols"
1954        );
1955        assert!(
1956            repair_count > 0,
1957            "bead_id={TEST_BEAD_ID} case=has_repair_symbols"
1958        );
1959        assert_eq!(
1960            source_count, k_source,
1961            "bead_id={TEST_BEAD_ID} case=source_count_matches_k"
1962        );
1963        assert_eq!(
1964            last_isi,
1965            k_source * 2 - 1,
1966            "bead_id={TEST_BEAD_ID} case=max_isi_reached"
1967        );
1968    }
1969
1970    #[test]
1971    fn test_streaming_systematic_first_ordering() {
1972        let mut sender = ReplicationSender::new();
1973        let mut pages = make_pages(512, &[1, 2]);
1974        let config = SenderConfig {
1975            symbol_size: 512,
1976            max_isi_multiplier: 2,
1977        };
1978        sender.prepare(512, &mut pages, config).expect("prepare");
1979        sender.start_streaming().expect("start");
1980
1981        let session = sender.session.as_ref().expect("session");
1982        let k_source = session.shards[0].k_source;
1983        let k_source_usize = usize::try_from(k_source).expect("K_source fits usize");
1984
1985        let mut observed_esis = Vec::new();
1986        while let Some(packet) = sender.next_packet().expect("next") {
1987            observed_esis.push(packet.esi);
1988        }
1989
1990        assert!(
1991            observed_esis.len() >= k_source_usize,
1992            "bead_id={TEST_BEAD_ID} case=have_at_least_k_source_packets"
1993        );
1994
1995        let expected_systematic: Vec<u32> = (0..k_source).collect();
1996        assert_eq!(
1997            &observed_esis[..k_source_usize],
1998            expected_systematic.as_slice(),
1999            "bead_id={TEST_BEAD_ID} case=systematic_first_ordering"
2000        );
2001
2002        if observed_esis.len() > k_source_usize {
2003            assert!(
2004                observed_esis[k_source_usize] >= k_source,
2005                "bead_id={TEST_BEAD_ID} case=repair_starts_after_systematic"
2006            );
2007        }
2008    }
2009
2010    #[test]
2011    fn test_streaming_schedule_deterministic_across_runs() {
2012        fn collect_packets(
2013            page_size: u32,
2014            page_numbers: &[u32],
2015            config: &SenderConfig,
2016        ) -> Vec<ReplicationPacket> {
2017            let mut sender = ReplicationSender::new();
2018            let mut pages = make_pages(page_size, page_numbers);
2019            sender
2020                .prepare(page_size, &mut pages, config.clone())
2021                .expect("prepare");
2022            sender.start_streaming().expect("start");
2023
2024            let mut packets = Vec::new();
2025            while let Some(packet) = sender.next_packet().expect("next") {
2026                packets.push(packet);
2027            }
2028            packets
2029        }
2030
2031        let config = SenderConfig {
2032            symbol_size: 256,
2033            max_isi_multiplier: 2,
2034        };
2035        let run_a = collect_packets(512, &[1, 3, 2], &config);
2036        let run_b = collect_packets(512, &[1, 3, 2], &config);
2037
2038        assert_eq!(
2039            run_a.len(),
2040            run_b.len(),
2041            "bead_id={TEST_BEAD_ID} case=deterministic_run_packet_count"
2042        );
2043        assert_eq!(
2044            run_a, run_b,
2045            "bead_id={TEST_BEAD_ID} case=deterministic_schedule_reproducible"
2046        );
2047    }
2048
2049    #[test]
2050    fn test_streaming_stop_on_ack() {
2051        let mut sender = ReplicationSender::new();
2052        let mut pages = make_pages(512, &[1]);
2053        sender
2054            .prepare(512, &mut pages, SenderConfig::default())
2055            .expect("prepare");
2056        sender.start_streaming().expect("start");
2057
2058        // Generate a few packets.
2059        let _p1 = sender.next_packet().expect("next").expect("packet");
2060
2061        // Receiver ACKs completion.
2062        sender.acknowledge_complete().expect("ack");
2063        assert_eq!(
2064            sender.state(),
2065            SenderState::Complete,
2066            "bead_id={TEST_BEAD_ID} case=stop_on_ack"
2067        );
2068        assert!(
2069            sender.next_packet().is_err(),
2070            "bead_id={TEST_BEAD_ID} case=no_packets_after_ack_complete"
2071        );
2072    }
2073
2074    #[test]
2075    fn test_streaming_stop_on_max_isi() {
2076        let mut sender = ReplicationSender::new();
2077        let mut pages = make_pages(128, &[1]);
2078        let config = SenderConfig {
2079            symbol_size: 128,
2080            max_isi_multiplier: 2,
2081        };
2082        sender.prepare(128, &mut pages, config).expect("prepare");
2083        sender.start_streaming().expect("start");
2084
2085        let mut count = 0_u32;
2086        while sender.next_packet().expect("next").is_some() {
2087            count += 1;
2088        }
2089
2090        // Should have generated exactly k_source * max_isi_multiplier packets.
2091        let session = sender.session.as_ref().unwrap();
2092        let expected = session.shards[0].k_source * 2;
2093        assert_eq!(
2094            count, expected,
2095            "bead_id={TEST_BEAD_ID} case=stop_on_max_isi"
2096        );
2097    }
2098
2099    #[test]
2100    fn test_block_size_limit_sharding() {
2101        // Create a changeset that exceeds K_MAX source symbols.
2102        let symbol_size = 64_u16;
2103        let bytes_per_max_block = u64::from(K_MAX) * u64::from(symbol_size);
2104        // Make changeset bytes just over the limit.
2105        let changeset_bytes = vec![0xAB_u8; usize::try_from(bytes_per_max_block).unwrap() + 1];
2106        let shards = shard_changeset(changeset_bytes.clone(), symbol_size).expect("shard");
2107
2108        assert!(
2109            shards.len() > 1,
2110            "bead_id={TEST_BEAD_ID} case=sharding_triggered shards={}",
2111            shards.len()
2112        );
2113
2114        // Each shard has k_source <= K_MAX.
2115        for (i, shard) in shards.iter().enumerate() {
2116            assert!(
2117                shard.k_source <= K_MAX,
2118                "bead_id={TEST_BEAD_ID} case=shard_k_max shard={i} k_source={}",
2119                shard.k_source
2120            );
2121        }
2122
2123        // All bytes covered.
2124        let total_bytes: usize = shards.iter().map(|s| s.changeset_bytes.len()).sum();
2125        assert_eq!(
2126            total_bytes,
2127            changeset_bytes.len(),
2128            "bead_id={TEST_BEAD_ID} case=sharding_coverage"
2129        );
2130    }
2131
2132    // -----------------------------------------------------------------------
2133    // Property tests
2134    // -----------------------------------------------------------------------
2135
2136    #[test]
2137    fn prop_changeset_id_unique() {
2138        let page_size = 128_u32;
2139        let mut ids = Vec::new();
2140        for seed in 0_u32..20 {
2141            let mut pages = vec![PageEntry::new(
2142                1,
2143                vec![u8::try_from(seed).unwrap(); page_size as usize],
2144            )];
2145            let bytes = encode_changeset(page_size, &mut pages).expect("encode");
2146            ids.push(compute_changeset_id(&bytes));
2147        }
2148
2149        // All IDs should be unique.
2150        for i in 0..ids.len() {
2151            for j in (i + 1)..ids.len() {
2152                assert_ne!(
2153                    ids[i], ids[j],
2154                    "bead_id={TEST_BEAD_ID} case=prop_id_unique i={i} j={j}"
2155                );
2156            }
2157        }
2158    }
2159
2160    #[test]
2161    fn prop_sharding_covers_all_pages() {
2162        let symbol_size = 64_u16;
2163        for size_multiplier in [1_u64, 2, 5] {
2164            let total = u64::from(K_MAX) * u64::from(symbol_size) * size_multiplier + 7;
2165            let changeset = vec![0xCC_u8; usize::try_from(total).unwrap()];
2166            let shards = shard_changeset(changeset.clone(), symbol_size).expect("shard");
2167
2168            let reassembled: Vec<u8> = shards
2169                .iter()
2170                .flat_map(|s| s.changeset_bytes.iter().copied())
2171                .collect();
2172
2173            assert_eq!(
2174                reassembled, changeset,
2175                "bead_id={TEST_BEAD_ID} case=prop_sharding_coverage multiplier={size_multiplier}"
2176            );
2177        }
2178    }
2179
2180    // -----------------------------------------------------------------------
2181    // Compliance tests
2182    // -----------------------------------------------------------------------
2183
2184    #[test]
2185    fn test_bd_1hi_13_unit_compliance_gate() {
2186        assert_eq!(CHANGESET_MAGIC, *b"FSRP");
2187        assert_eq!(CHANGESET_VERSION, 1);
2188        assert_eq!(CHANGESET_HEADER_SIZE, 22);
2189        assert_eq!(REPLICATION_HEADER_SIZE_LEGACY, 24);
2190        assert_eq!(REPLICATION_HEADER_SIZE, 72);
2191        assert_eq!(REPLICATION_HEADER_SIZE_V2, 72);
2192        assert_eq!(MAX_UDP_PAYLOAD, 65_507);
2193        const { assert!(MAX_REPLICATION_SYMBOL_SIZE < MAX_UDP_PAYLOAD) };
2194
2195        // Verify core functions exist.
2196        let _ = ChangesetId::from_bytes([0; 16]);
2197        let _ = compute_changeset_id(b"test");
2198        let _ = derive_seed_from_changeset_id(&ChangesetId::from_bytes([0; 16]));
2199    }
2200
2201    #[test]
2202    fn prop_bd_1hi_13_structure_compliance() {
2203        // State machine transitions are correct.
2204        let mut sender = ReplicationSender::new();
2205        assert_eq!(sender.state(), SenderState::Idle);
2206
2207        let mut pages = make_pages(256, &[1, 2]);
2208        sender
2209            .prepare(256, &mut pages, SenderConfig::default())
2210            .expect("prepare");
2211        assert_eq!(sender.state(), SenderState::Encoding);
2212
2213        sender.start_streaming().expect("start");
2214        assert_eq!(sender.state(), SenderState::Streaming);
2215
2216        sender.complete();
2217        assert_eq!(sender.state(), SenderState::Complete);
2218
2219        sender.reset();
2220        assert_eq!(sender.state(), SenderState::Idle);
2221    }
2222
2223    // -----------------------------------------------------------------------
2224    // §4.19.6 networking policy tests (bd-i0m5)
2225    // -----------------------------------------------------------------------
2226
2227    #[test]
2228    fn test_tls_by_default() {
2229        let cfg = NetworkStackConfig::default();
2230        assert_eq!(cfg.security, TransportSecurityMode::RustlsTls);
2231        assert!(cfg.validate_security().is_ok());
2232    }
2233
2234    #[test]
2235    fn test_plaintext_requires_explicit_opt_in() {
2236        let cfg = NetworkStackConfig {
2237            security: TransportSecurityMode::Plaintext,
2238            explicit_plaintext_opt_in: false,
2239            ..NetworkStackConfig::default()
2240        };
2241        let err = cfg.validate_security().unwrap_err();
2242        assert!(matches!(err, FrankenError::Unsupported));
2243
2244        let opted_in = NetworkStackConfig::plaintext_local_dev(true).unwrap();
2245        assert_eq!(opted_in.security, TransportSecurityMode::Plaintext);
2246        assert!(opted_in.validate_security().is_ok());
2247    }
2248
2249    #[test]
2250    fn test_http2_max_concurrent_streams() {
2251        let cfg = NetworkStackConfig::default();
2252        assert!(
2253            cfg.validate_concurrent_streams(DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS)
2254                .is_ok()
2255        );
2256        let err = cfg
2257            .validate_concurrent_streams(DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS + 1)
2258            .unwrap_err();
2259        assert!(matches!(err, FrankenError::Busy));
2260    }
2261
2262    #[test]
2263    fn test_http2_max_header_list_size() {
2264        let cfg = NetworkStackConfig::default();
2265        assert!(
2266            cfg.validate_header_list_size(DEFAULT_HTTP2_MAX_HEADER_LIST_SIZE)
2267                .is_ok()
2268        );
2269        let err = cfg
2270            .validate_header_list_size(DEFAULT_HTTP2_MAX_HEADER_LIST_SIZE + 1)
2271            .unwrap_err();
2272        assert!(matches!(err, FrankenError::TooBig));
2273    }
2274
2275    #[test]
2276    fn test_http2_continuation_timeout() {
2277        let cfg = NetworkStackConfig::default();
2278        assert!(
2279            cfg.validate_continuation_elapsed(DEFAULT_HTTP2_CONTINUATION_TIMEOUT_MS)
2280                .is_ok()
2281        );
2282        let err = cfg
2283            .validate_continuation_elapsed(DEFAULT_HTTP2_CONTINUATION_TIMEOUT_MS + 1)
2284            .unwrap_err();
2285        assert!(matches!(err, FrankenError::BusyRecovery));
2286    }
2287
2288    #[test]
2289    fn test_message_size_cap_enforced() {
2290        let cfg = NetworkStackConfig::default();
2291        assert!(
2292            cfg.validate_message_size(DEFAULT_RPC_MESSAGE_CAP_BYTES)
2293                .is_ok()
2294        );
2295        let err = cfg
2296            .validate_message_size(DEFAULT_RPC_MESSAGE_CAP_BYTES + 1)
2297            .unwrap_err();
2298        assert!(matches!(err, FrankenError::TooBig));
2299    }
2300
2301    #[test]
2302    fn test_handshake_timeout_bounded() {
2303        let cfg = NetworkStackConfig {
2304            handshake_timeout_ms: DEFAULT_HANDSHAKE_TIMEOUT_MS,
2305            ..NetworkStackConfig::default()
2306        };
2307        assert!(
2308            cfg.validate_handshake_elapsed(DEFAULT_HANDSHAKE_TIMEOUT_MS)
2309                .is_ok()
2310        );
2311        let err = cfg
2312            .validate_handshake_elapsed(DEFAULT_HANDSHAKE_TIMEOUT_MS + 500)
2313            .unwrap_err();
2314        assert!(matches!(err, FrankenError::BusyRecovery));
2315    }
2316
2317    #[test]
2318    fn test_virtual_tcp_deterministic() {
2319        let faults = VirtualTcpFaultProfile {
2320            drop_per_million: 150_000,
2321            reorder_per_million: 200_000,
2322            corrupt_per_million: 125_000,
2323        };
2324        let payloads = vec![
2325            b"alpha".to_vec(),
2326            b"beta".to_vec(),
2327            b"gamma".to_vec(),
2328            b"delta".to_vec(),
2329            b"epsilon".to_vec(),
2330        ];
2331
2332        let mut left = VirtualTcp::new(42, faults).unwrap();
2333        let mut left_out = Vec::new();
2334        for payload in &payloads {
2335            left_out.extend(left.transmit(payload));
2336        }
2337        if let Some(flush) = left.flush() {
2338            left_out.push(flush);
2339        }
2340        let left_trace = left.trace().to_vec();
2341
2342        let mut right = VirtualTcp::new(42, faults).unwrap();
2343        let mut right_out = Vec::new();
2344        for payload in &payloads {
2345            right_out.extend(right.transmit(payload));
2346        }
2347        if let Some(flush) = right.flush() {
2348            right_out.push(flush);
2349        }
2350        let right_trace = right.trace().to_vec();
2351
2352        assert_eq!(left_out, right_out);
2353        assert_eq!(left_trace, right_trace);
2354    }
2355
2356    #[test]
2357    fn test_virtual_tcp_fault_injection() {
2358        let mut vtcp = VirtualTcp::new(
2359            7,
2360            VirtualTcpFaultProfile {
2361                drop_per_million: 0,
2362                reorder_per_million: 1_000_000,
2363                corrupt_per_million: 1_000_000,
2364            },
2365        )
2366        .unwrap();
2367
2368        let out_first = vtcp.transmit(b"packet-a");
2369        assert!(out_first.is_empty(), "first packet must be buffered");
2370
2371        let out_second = vtcp.transmit(b"packet-b");
2372        assert_eq!(out_second.len(), 2, "second transmit flushes reorder queue");
2373        assert_ne!(
2374            out_second[0],
2375            b"packet-b".to_vec(),
2376            "corruption must alter delivered payload"
2377        );
2378
2379        let has_buffer = vtcp
2380            .trace()
2381            .iter()
2382            .any(|event| event.kind == VirtualTcpTraceKind::BufferedForReorder);
2383        let has_corrupt_delivery = vtcp
2384            .trace()
2385            .iter()
2386            .any(|event| event.kind == VirtualTcpTraceKind::DeliveredCorrupt);
2387        let has_flush = vtcp
2388            .trace()
2389            .iter()
2390            .any(|event| event.kind == VirtualTcpTraceKind::FlushedReordered);
2391
2392        assert!(has_buffer);
2393        assert!(has_corrupt_delivery);
2394        assert!(has_flush);
2395    }
2396}