Skip to main content

detcore_model/
network_trace.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Versioned data model for schedule-independent external network input.
10//!
11//! This module deliberately contains no recorder, replayer, or scheduler hook.
12//! It defines the fail-closed v1 envelope and its framing so a future runtime
13//! integration cannot accidentally reuse the schedule-coupled syscall event
14//! stream. Merely constructing a [`NetworkTraceConfig`] does not enable any
15//! behavior today.
16
17use std::error::Error;
18use std::fmt;
19use std::io;
20use std::io::Read;
21use std::io::Write;
22use std::net::Ipv4Addr;
23use std::net::Ipv6Addr;
24use std::path::PathBuf;
25
26use chrono::DateTime;
27use chrono::Utc;
28use serde::Deserialize;
29use serde::Serialize;
30
31use crate::fd::OpenFileId;
32use crate::time::LogicalTime;
33
34/// The on-disk format magic. The version is stored in the following four bytes.
35pub const NETWORK_TRACE_MAGIC: [u8; 16] = *b"HERMIT-NET-TRACE";
36/// The only network trace format this build accepts.
37pub const NETWORK_TRACE_VERSION_V1: u32 = 1;
38/// Refuse hostile or corrupt length headers before allocating memory.
39pub const MAX_NETWORK_TRACE_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024;
40
41const FRAME_HEADER_LEN: usize = NETWORK_TRACE_MAGIC.len() + 4 + 8;
42
43/// Whether the future runtime integration records or replays external input.
44#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum NetworkTraceMode {
47    /// No network trace behavior. This is the default and has no runtime effect.
48    #[default]
49    Off,
50    /// Capture supported external TCP input in a new trace.
51    Record,
52    /// Replay a trace without consulting the host network.
53    Replay,
54}
55
56/// Configuration reserved for the future network recorder/replayer seam.
57///
58/// `network_perturb_seed` is intentionally optional and has no fallback to the
59/// scheduler or global seed. A future perturbation layer must consume only this
60/// seed so varying `sched_seed` cannot change the external input stream.
61#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct NetworkTraceConfig {
64    pub mode: NetworkTraceMode,
65    pub path: Option<PathBuf>,
66    pub network_perturb_seed: Option<u64>,
67}
68
69impl NetworkTraceConfig {
70    /// Validate configuration independently of any scheduler configuration.
71    pub fn validate(&self) -> Result<(), NetworkTraceConfigError> {
72        match self.mode {
73            NetworkTraceMode::Off => {
74                if self.path.is_some() || self.network_perturb_seed.is_some() {
75                    return Err(NetworkTraceConfigError::OptionsWhileOff);
76                }
77            }
78            NetworkTraceMode::Record => {
79                validate_trace_path(self.path.as_ref())?;
80                if self.network_perturb_seed.is_some() {
81                    return Err(NetworkTraceConfigError::PerturbationDuringRecord);
82                }
83            }
84            NetworkTraceMode::Replay => validate_trace_path(self.path.as_ref())?,
85        }
86        Ok(())
87    }
88}
89
90fn validate_trace_path(path: Option<&PathBuf>) -> Result<(), NetworkTraceConfigError> {
91    let Some(path) = path else {
92        return Err(NetworkTraceConfigError::MissingPath);
93    };
94    if path.as_os_str().is_empty() {
95        return Err(NetworkTraceConfigError::EmptyPath);
96    }
97    Ok(())
98}
99
100/// Invalid combinations in [`NetworkTraceConfig`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum NetworkTraceConfigError {
103    OptionsWhileOff,
104    MissingPath,
105    EmptyPath,
106    PerturbationDuringRecord,
107}
108
109impl fmt::Display for NetworkTraceConfigError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::OptionsWhileOff => {
113                write!(f, "network trace options require record or replay mode")
114            }
115            Self::MissingPath => write!(f, "network trace record/replay requires a path"),
116            Self::EmptyPath => write!(f, "network trace path must not be empty"),
117            Self::PerturbationDuringRecord => {
118                write!(f, "network perturbation is replay-only")
119            }
120        }
121    }
122}
123
124impl Error for NetworkTraceConfigError {}
125
126/// Internet address recorded without host-layout `sockaddr` padding.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case", deny_unknown_fields)]
129pub enum NetworkAddressV1 {
130    Inet4 {
131        address: [u8; 4],
132        port: u16,
133    },
134    Inet6 {
135        address: [u8; 16],
136        port: u16,
137        flowinfo: u32,
138        scope_id: u32,
139    },
140}
141
142impl NetworkAddressV1 {
143    fn is_supported_external_peer(&self) -> bool {
144        match self {
145            Self::Inet4 { address, port } => {
146                *port != 0 && is_supported_external_ipv4(Ipv4Addr::from(*address))
147            }
148            Self::Inet6 { address, port, .. } => {
149                let address = Ipv6Addr::from(*address);
150                *port != 0
151                    && match address.to_ipv4_mapped() {
152                        Some(mapped) => is_supported_external_ipv4(mapped),
153                        None => {
154                            !address.is_loopback()
155                                && !address.is_unspecified()
156                                && !address.is_multicast()
157                        }
158                    }
159            }
160        }
161    }
162
163    fn same_family(&self, other: &Self) -> bool {
164        matches!(
165            (self, other),
166            (Self::Inet4 { .. }, Self::Inet4 { .. }) | (Self::Inet6 { .. }, Self::Inet6 { .. })
167        )
168    }
169}
170
171fn is_supported_external_ipv4(address: Ipv4Addr) -> bool {
172    !address.is_loopback()
173        && !address.is_unspecified()
174        && !address.is_multicast()
175        && !address.is_broadcast()
176}
177
178/// Transport admitted by the v1 trace envelope.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum NetworkTransportV1 {
182    Tcp,
183}
184
185/// Connection role admitted by v1. Server-side `accept` needs a different
186/// identity and arrival model and is intentionally not representable.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum NetworkEndpointRoleV1 {
190    OutboundClient,
191}
192
193/// The only channel supported by v1: one outbound TCP client connection.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct NetworkChannelV1 {
197    /// Stable Detcore open-file-description identity, never a raw fd.
198    pub id: OpenFileId,
199    pub transport: NetworkTransportV1,
200    pub role: NetworkEndpointRoleV1,
201    pub local_address: NetworkAddressV1,
202    pub peer_address: NetworkAddressV1,
203    /// The recorder must prove socket allocation occurred before competing
204    /// guest threads could make this identity schedule-dependent.
205    pub created_before_competing_threads: bool,
206}
207
208/// Conditions that must both hold before an input becomes observable.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct NetworkReleaseV1 {
212    /// Absolute time in exactly the domain returned by `GlobalTime::as_nanos`.
213    /// It includes the trace epoch; it is not a duration since that epoch.
214    pub not_before_global_time: LogicalTime,
215    /// Number of validated outbound bytes required before release.
216    pub after_transmitted_offset: u64,
217}
218
219impl NetworkReleaseV1 {
220    /// Whether both scheduler-owned eligibility conditions have been met.
221    pub fn is_eligible(
222        &self,
223        committed_global_time: LogicalTime,
224        validated_transmitted_offset: u64,
225    ) -> bool {
226        committed_global_time >= self.not_before_global_time
227            && validated_transmitted_offset >= self.after_transmitted_offset
228    }
229}
230
231/// Supported external observations for the initial TCP byte-stream model.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case", deny_unknown_fields)]
234pub enum NetworkInputKindV1 {
235    InboundBytes {
236        stream_offset: u64,
237        bytes: Vec<u8>,
238    },
239    PeerWriteClosed {
240        stream_offset: u64,
241    },
242    /// A terminal stream error, not a retryable or interrupted receive attempt.
243    SocketError {
244        stream_offset: u64,
245        errno: i32,
246    },
247}
248
249/// One globally ordered external observation.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(deny_unknown_fields)]
252pub struct NetworkInputEventV1 {
253    pub ordinal: u64,
254    pub channel: OpenFileId,
255    pub release: NetworkReleaseV1,
256    pub event: NetworkInputKindV1,
257}
258
259/// One contiguous recorded fragment of the expected outbound TCP byte stream.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct NetworkOutputV1 {
263    pub channel: OpenFileId,
264    pub stream_offset: u64,
265    pub bytes: Vec<u8>,
266}
267
268/// Version-one schedule-independent network input trace.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct NetworkTraceV1 {
272    pub epoch: DateTime<Utc>,
273    pub channels: Vec<NetworkChannelV1>,
274    pub inputs: Vec<NetworkInputEventV1>,
275    pub outputs: Vec<NetworkOutputV1>,
276}
277
278impl NetworkTraceV1 {
279    /// Convert the recorded epoch to the absolute starting time used by
280    /// `GlobalTime`, rejecting Chrono values outside its `u64` nanosecond
281    /// domain. `GlobalTime` truncates the epoch to microseconds, so this does
282    /// the same conversion without an unchecked signed cast or multiplication.
283    pub fn epoch_global_time(&self) -> Result<LogicalTime, NetworkTraceValidationError> {
284        let seconds = u64::try_from(self.epoch.timestamp())
285            .map_err(|_| NetworkTraceValidationError::EpochOutOfRange)?;
286        let whole_seconds = seconds
287            .checked_mul(1_000_000_000)
288            .ok_or(NetworkTraceValidationError::EpochOutOfRange)?;
289        let fractional_micros = u64::from(self.epoch.timestamp_subsec_micros())
290            .checked_mul(1_000)
291            .ok_or(NetworkTraceValidationError::EpochOutOfRange)?;
292        whole_seconds
293            .checked_add(fractional_micros)
294            .map(LogicalTime::from_nanos)
295            .ok_or(NetworkTraceValidationError::EpochOutOfRange)
296    }
297
298    /// Enforce the narrow v1 envelope before either writing or consuming a trace.
299    pub fn validate(&self) -> Result<(), NetworkTraceValidationError> {
300        if self.channels.len() != 1 {
301            return Err(NetworkTraceValidationError::ChannelCount(
302                self.channels.len(),
303            ));
304        }
305        let channel = &self.channels[0];
306        if !channel.id.is_socket() {
307            return Err(NetworkTraceValidationError::NonSocketChannelIdentity);
308        }
309        if !channel.created_before_competing_threads {
310            return Err(NetworkTraceValidationError::ScheduleDependentChannelIdentity);
311        }
312        if !channel.local_address.same_family(&channel.peer_address) {
313            return Err(NetworkTraceValidationError::AddressFamilyMismatch);
314        }
315        if !channel.peer_address.is_supported_external_peer() {
316            return Err(NetworkTraceValidationError::UnsupportedPeerAddress);
317        }
318
319        let mut inbound_offset = 0u64;
320        let epoch_start = self.epoch_global_time()?;
321        let mut previous_time = epoch_start;
322        let mut previous_tx_watermark = 0u64;
323        let mut terminal = false;
324
325        let total_output = validate_outputs(&self.outputs, channel.id)?;
326        for (index, input) in self.inputs.iter().enumerate() {
327            if input.ordinal != index as u64 {
328                return Err(NetworkTraceValidationError::NonCanonicalOrdinal);
329            }
330            if input.channel != channel.id {
331                return Err(NetworkTraceValidationError::UnknownChannel);
332            }
333            if input.release.not_before_global_time < epoch_start {
334                return Err(NetworkTraceValidationError::ReleaseBeforeEpoch);
335            }
336            if input.release.not_before_global_time < previous_time
337                || input.release.after_transmitted_offset < previous_tx_watermark
338            {
339                return Err(NetworkTraceValidationError::NonMonotonicRelease);
340            }
341            if input.release.after_transmitted_offset > total_output {
342                return Err(NetworkTraceValidationError::UnreachableTransmitWatermark);
343            }
344            if terminal {
345                return Err(NetworkTraceValidationError::EventAfterTerminal);
346            }
347
348            match &input.event {
349                NetworkInputKindV1::InboundBytes {
350                    stream_offset,
351                    bytes,
352                } => {
353                    if bytes.is_empty() {
354                        return Err(NetworkTraceValidationError::EmptyByteChunk);
355                    }
356                    if *stream_offset != inbound_offset {
357                        return Err(NetworkTraceValidationError::NonContiguousInput);
358                    }
359                    inbound_offset = inbound_offset
360                        .checked_add(bytes.len() as u64)
361                        .ok_or(NetworkTraceValidationError::StreamOffsetOverflow)?;
362                }
363                NetworkInputKindV1::PeerWriteClosed { stream_offset } => {
364                    if *stream_offset != inbound_offset {
365                        return Err(NetworkTraceValidationError::NonContiguousInput);
366                    }
367                    terminal = true;
368                }
369                NetworkInputKindV1::SocketError {
370                    stream_offset,
371                    errno,
372                } => {
373                    if *stream_offset != inbound_offset {
374                        return Err(NetworkTraceValidationError::NonContiguousInput);
375                    }
376                    if !(1..=4095).contains(errno) {
377                        return Err(NetworkTraceValidationError::InvalidErrno);
378                    }
379                    // EWOULDBLOCK equals EAGAIN on the supported Linux hosts.
380                    // Neither readiness nor signal interruption terminates the
381                    // peer's byte stream, so neither belongs in this variant.
382                    if matches!(*errno, libc::EAGAIN | libc::EINTR) {
383                        return Err(NetworkTraceValidationError::NonTerminalSocketError);
384                    }
385                    terminal = true;
386                }
387            }
388            previous_time = input.release.not_before_global_time;
389            previous_tx_watermark = input.release.after_transmitted_offset;
390        }
391        Ok(())
392    }
393
394    /// Write one complete, length-delimited v1 trace.
395    pub fn write_framed<W: Write>(&self, mut writer: W) -> Result<(), NetworkTraceCodecError> {
396        self.validate()?;
397        let payload = bincode::serde::encode_to_vec(self, bincode::config::standard())
398            .map_err(NetworkTraceCodecError::Encode)?;
399        let payload_len =
400            u64::try_from(payload.len()).map_err(|_| NetworkTraceCodecError::TooLarge)?;
401        if payload_len > MAX_NETWORK_TRACE_PAYLOAD_BYTES {
402            return Err(NetworkTraceCodecError::TooLarge);
403        }
404        writer.write_all(&NETWORK_TRACE_MAGIC)?;
405        writer.write_all(&NETWORK_TRACE_VERSION_V1.to_le_bytes())?;
406        writer.write_all(&payload_len.to_le_bytes())?;
407        writer.write_all(&payload)?;
408        Ok(())
409    }
410
411    /// Read exactly one complete trace, rejecting truncation, trailing bytes,
412    /// unknown versions, malformed payloads, and invalid v1 semantics.
413    pub fn read_framed<R: Read>(mut reader: R) -> Result<Self, NetworkTraceCodecError> {
414        let mut header = [0u8; FRAME_HEADER_LEN];
415        read_exact_or_truncated(&mut reader, &mut header)?;
416        if header[..NETWORK_TRACE_MAGIC.len()] != NETWORK_TRACE_MAGIC {
417            return Err(NetworkTraceCodecError::BadMagic);
418        }
419        let version_start = NETWORK_TRACE_MAGIC.len();
420        let version = u32::from_le_bytes(
421            header[version_start..version_start + 4]
422                .try_into()
423                .expect("fixed-size version field"),
424        );
425        if version != NETWORK_TRACE_VERSION_V1 {
426            return Err(NetworkTraceCodecError::UnsupportedVersion(version));
427        }
428        let len_start = version_start + 4;
429        let payload_len = u64::from_le_bytes(
430            header[len_start..len_start + 8]
431                .try_into()
432                .expect("fixed-size length field"),
433        );
434        if payload_len > MAX_NETWORK_TRACE_PAYLOAD_BYTES {
435            return Err(NetworkTraceCodecError::TooLarge);
436        }
437        let payload_len =
438            usize::try_from(payload_len).map_err(|_| NetworkTraceCodecError::TooLarge)?;
439        let mut payload = vec![0; payload_len];
440        read_exact_or_truncated(&mut reader, &mut payload)?;
441        let mut trailing = [0u8; 1];
442        if reader.read(&mut trailing)? != 0 {
443            return Err(NetworkTraceCodecError::TrailingData);
444        }
445        let (trace, consumed): (Self, usize) =
446            bincode::serde::decode_from_slice(&payload, bincode::config::standard())
447                .map_err(NetworkTraceCodecError::Decode)?;
448        if consumed != payload.len() {
449            return Err(NetworkTraceCodecError::TrailingPayloadData);
450        }
451        trace.validate()?;
452        Ok(trace)
453    }
454}
455
456fn validate_outputs(
457    outputs: &[NetworkOutputV1],
458    channel: OpenFileId,
459) -> Result<u64, NetworkTraceValidationError> {
460    let mut expected_offset = 0u64;
461    for output in outputs {
462        if output.channel != channel {
463            return Err(NetworkTraceValidationError::UnknownChannel);
464        }
465        if output.bytes.is_empty() {
466            return Err(NetworkTraceValidationError::EmptyByteChunk);
467        }
468        if output.stream_offset != expected_offset {
469            return Err(NetworkTraceValidationError::NonContiguousOutput);
470        }
471        expected_offset = expected_offset
472            .checked_add(output.bytes.len() as u64)
473            .ok_or(NetworkTraceValidationError::StreamOffsetOverflow)?;
474    }
475    Ok(expected_offset)
476}
477
478fn read_exact_or_truncated<R: Read>(
479    reader: &mut R,
480    bytes: &mut [u8],
481) -> Result<(), NetworkTraceCodecError> {
482    reader.read_exact(bytes).map_err(|error| {
483        if error.kind() == io::ErrorKind::UnexpectedEof {
484            NetworkTraceCodecError::Truncated
485        } else {
486            NetworkTraceCodecError::Io(error)
487        }
488    })
489}
490
491/// Semantically invalid v1 trace.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub enum NetworkTraceValidationError {
494    ChannelCount(usize),
495    EpochOutOfRange,
496    NonSocketChannelIdentity,
497    ScheduleDependentChannelIdentity,
498    AddressFamilyMismatch,
499    UnsupportedPeerAddress,
500    UnknownChannel,
501    NonCanonicalOrdinal,
502    ReleaseBeforeEpoch,
503    NonMonotonicRelease,
504    UnreachableTransmitWatermark,
505    EmptyByteChunk,
506    NonContiguousInput,
507    NonContiguousOutput,
508    StreamOffsetOverflow,
509    InvalidErrno,
510    NonTerminalSocketError,
511    EventAfterTerminal,
512}
513
514impl fmt::Display for NetworkTraceValidationError {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        write!(f, "invalid network trace: {self:?}")
517    }
518}
519
520impl Error for NetworkTraceValidationError {}
521
522/// Failure to decode or encode the framed v1 representation.
523#[derive(Debug)]
524pub enum NetworkTraceCodecError {
525    Io(io::Error),
526    Truncated,
527    BadMagic,
528    UnsupportedVersion(u32),
529    TooLarge,
530    TrailingData,
531    TrailingPayloadData,
532    Encode(bincode::error::EncodeError),
533    Decode(bincode::error::DecodeError),
534    Validation(NetworkTraceValidationError),
535}
536
537impl fmt::Display for NetworkTraceCodecError {
538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539        write!(f, "network trace codec error: {self:?}")
540    }
541}
542
543impl Error for NetworkTraceCodecError {}
544
545impl From<io::Error> for NetworkTraceCodecError {
546    fn from(error: io::Error) -> Self {
547        Self::Io(error)
548    }
549}
550
551impl From<NetworkTraceValidationError> for NetworkTraceCodecError {
552    fn from(error: NetworkTraceValidationError) -> Self {
553        Self::Validation(error)
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use std::io::Cursor;
560    use std::net::Ipv4Addr;
561    use std::time::Duration;
562
563    use chrono::TimeZone;
564
565    use super::*;
566    use crate::config::Config;
567    use crate::pid::DetTid;
568    use crate::time::GlobalTime;
569
570    fn channel_id() -> OpenFileId {
571        OpenFileId::new_socket(DetTid::from_raw(1), 0)
572    }
573
574    fn trace_epoch() -> DateTime<Utc> {
575        Utc.timestamp_opt(1_790_000_000, 0).unwrap()
576    }
577
578    fn global_time_after_epoch(nanos: u64) -> LogicalTime {
579        let config = Config {
580            epoch: trace_epoch(),
581            ..Config::default()
582        };
583        GlobalTime::new(&config).as_nanos() + LogicalTime::from_nanos(nanos)
584    }
585
586    fn valid_trace() -> NetworkTraceV1 {
587        let channel = channel_id();
588        NetworkTraceV1 {
589            epoch: trace_epoch(),
590            channels: vec![NetworkChannelV1 {
591                id: channel,
592                transport: NetworkTransportV1::Tcp,
593                role: NetworkEndpointRoleV1::OutboundClient,
594                local_address: NetworkAddressV1::Inet4 {
595                    address: [10, 0, 0, 2],
596                    port: 40_000,
597                },
598                peer_address: NetworkAddressV1::Inet4 {
599                    address: [192, 0, 2, 10],
600                    port: 443,
601                },
602                created_before_competing_threads: true,
603            }],
604            inputs: vec![
605                NetworkInputEventV1 {
606                    ordinal: 0,
607                    channel,
608                    release: NetworkReleaseV1 {
609                        not_before_global_time: global_time_after_epoch(10),
610                        after_transmitted_offset: 3,
611                    },
612                    event: NetworkInputKindV1::InboundBytes {
613                        stream_offset: 0,
614                        bytes: b"response".to_vec(),
615                    },
616                },
617                NetworkInputEventV1 {
618                    ordinal: 1,
619                    channel,
620                    release: NetworkReleaseV1 {
621                        not_before_global_time: global_time_after_epoch(20),
622                        after_transmitted_offset: 3,
623                    },
624                    event: NetworkInputKindV1::PeerWriteClosed { stream_offset: 8 },
625                },
626            ],
627            outputs: vec![NetworkOutputV1 {
628                channel,
629                stream_offset: 0,
630                bytes: b"req".to_vec(),
631            }],
632        }
633    }
634
635    fn framed(trace: &NetworkTraceV1) -> Vec<u8> {
636        let mut bytes = Vec::new();
637        trace.write_framed(&mut bytes).unwrap();
638        bytes
639    }
640
641    fn framed_without_validation(trace: &NetworkTraceV1) -> Vec<u8> {
642        let payload = bincode::serde::encode_to_vec(trace, bincode::config::standard()).unwrap();
643        let mut bytes = Vec::new();
644        bytes.extend_from_slice(&NETWORK_TRACE_MAGIC);
645        bytes.extend_from_slice(&NETWORK_TRACE_VERSION_V1.to_le_bytes());
646        bytes.extend_from_slice(&(payload.len() as u64).to_le_bytes());
647        bytes.extend_from_slice(&payload);
648        bytes
649    }
650
651    #[test]
652    fn network_trace_v1_round_trips_exactly() {
653        let trace = valid_trace();
654        let decoded = NetworkTraceV1::read_framed(Cursor::new(framed(&trace))).unwrap();
655        assert_eq!(decoded, trace);
656    }
657
658    #[test]
659    fn transient_socket_errors_are_rejected_by_validation_writer_and_reader() {
660        assert_eq!(libc::EWOULDBLOCK, libc::EAGAIN);
661        for errno in [libc::EAGAIN, libc::EINTR] {
662            let mut trace = valid_trace();
663            trace.inputs[1].event = NetworkInputKindV1::SocketError {
664                stream_offset: 8,
665                errno,
666            };
667            assert_eq!(
668                trace.validate(),
669                Err(NetworkTraceValidationError::NonTerminalSocketError),
670                "transient errno {errno} cannot terminate the stream"
671            );
672
673            let mut output = b"existing output".to_vec();
674            assert!(matches!(
675                trace.write_framed(&mut output),
676                Err(NetworkTraceCodecError::Validation(
677                    NetworkTraceValidationError::NonTerminalSocketError
678                ))
679            ));
680            assert_eq!(output, b"existing output");
681            // Bypass the writer to prove untrusted serialized input is refused
682            // independently, rather than relying on the writer's validation.
683            assert!(matches!(
684                NetworkTraceV1::read_framed(Cursor::new(framed_without_validation(&trace))),
685                Err(NetworkTraceCodecError::Validation(
686                    NetworkTraceValidationError::NonTerminalSocketError
687                ))
688            ));
689        }
690    }
691
692    #[test]
693    fn terminal_socket_errors_preserve_framing_and_the_stream_boundary() {
694        for errno in [libc::ECONNRESET, libc::ETIMEDOUT] {
695            let mut trace = valid_trace();
696            trace.inputs[1].event = NetworkInputKindV1::SocketError {
697                stream_offset: 8,
698                errno,
699            };
700            assert_eq!(trace.validate(), Ok(()));
701            let bytes = framed(&trace);
702            assert_eq!(bytes, framed_without_validation(&trace));
703            assert_eq!(
704                NetworkTraceV1::read_framed(Cursor::new(bytes)).unwrap(),
705                trace
706            );
707
708            let mut late = trace.inputs[0].clone();
709            late.ordinal = 2;
710            late.release = trace.inputs[1].release;
711            late.event = NetworkInputKindV1::InboundBytes {
712                stream_offset: 8,
713                bytes: b"late".to_vec(),
714            };
715            trace.inputs.push(late);
716            assert_eq!(
717                trace.validate(),
718                Err(NetworkTraceValidationError::EventAfterTerminal)
719            );
720        }
721    }
722
723    #[test]
724    fn socket_errno_range_is_still_checked_before_framing() {
725        for errno in [-1, 0, 4096] {
726            let mut trace = valid_trace();
727            trace.inputs[1].event = NetworkInputKindV1::SocketError {
728                stream_offset: 8,
729                errno,
730            };
731            assert_eq!(
732                trace.validate(),
733                Err(NetworkTraceValidationError::InvalidErrno)
734            );
735            assert!(matches!(
736                trace.write_framed(Vec::new()),
737                Err(NetworkTraceCodecError::Validation(
738                    NetworkTraceValidationError::InvalidErrno
739                ))
740            ));
741            assert!(matches!(
742                NetworkTraceV1::read_framed(Cursor::new(framed_without_validation(&trace))),
743                Err(NetworkTraceCodecError::Validation(
744                    NetworkTraceValidationError::InvalidErrno
745                ))
746            ));
747        }
748    }
749
750    #[test]
751    fn every_strict_prefix_is_reported_as_truncated() {
752        let bytes = framed(&valid_trace());
753        for end in 0..bytes.len() {
754            assert!(
755                matches!(
756                    NetworkTraceV1::read_framed(Cursor::new(&bytes[..end])),
757                    Err(NetworkTraceCodecError::Truncated)
758                ),
759                "prefix of length {end} was not rejected as truncation"
760            );
761        }
762    }
763
764    #[test]
765    fn unknown_versions_are_rejected_before_payload_decode() {
766        let mut bytes = framed(&valid_trace());
767        let start = NETWORK_TRACE_MAGIC.len();
768        bytes[start..start + 4].copy_from_slice(&2u32.to_le_bytes());
769        assert!(matches!(
770            NetworkTraceV1::read_framed(Cursor::new(bytes)),
771            Err(NetworkTraceCodecError::UnsupportedVersion(2))
772        ));
773    }
774
775    #[test]
776    fn oversized_length_is_rejected_before_allocation() {
777        let mut bytes = Vec::new();
778        bytes.extend_from_slice(&NETWORK_TRACE_MAGIC);
779        bytes.extend_from_slice(&NETWORK_TRACE_VERSION_V1.to_le_bytes());
780        bytes.extend_from_slice(&(MAX_NETWORK_TRACE_PAYLOAD_BYTES + 1).to_le_bytes());
781        assert!(matches!(
782            NetworkTraceV1::read_framed(Cursor::new(bytes)),
783            Err(NetworkTraceCodecError::TooLarge)
784        ));
785    }
786
787    #[test]
788    fn trailing_file_data_is_rejected() {
789        let mut bytes = framed(&valid_trace());
790        bytes.push(0);
791        assert!(matches!(
792            NetworkTraceV1::read_framed(Cursor::new(bytes)),
793            Err(NetworkTraceCodecError::TrailingData)
794        ));
795    }
796
797    #[test]
798    fn reader_and_writer_both_refuse_semantically_invalid_traces() {
799        let mut trace = valid_trace();
800        trace.inputs[0].ordinal = 7;
801
802        assert!(matches!(
803            trace.write_framed(Vec::new()),
804            Err(NetworkTraceCodecError::Validation(
805                NetworkTraceValidationError::NonCanonicalOrdinal
806            ))
807        ));
808        assert!(matches!(
809            NetworkTraceV1::read_framed(Cursor::new(framed_without_validation(&trace))),
810            Err(NetworkTraceCodecError::Validation(
811                NetworkTraceValidationError::NonCanonicalOrdinal
812            ))
813        ));
814    }
815
816    #[test]
817    fn deserialized_pre_unix_epoch_is_a_typed_validation_error() {
818        let mut trace = valid_trace();
819        trace.epoch = Utc.timestamp_opt(-1, 0).unwrap();
820        assert!(matches!(
821            NetworkTraceV1::read_framed(Cursor::new(framed_without_validation(&trace))),
822            Err(NetworkTraceCodecError::Validation(
823                NetworkTraceValidationError::EpochOutOfRange
824            ))
825        ));
826    }
827
828    #[test]
829    fn deserialized_epoch_beyond_global_time_is_a_typed_validation_error() {
830        let mut trace = valid_trace();
831        trace.epoch = Utc.with_ymd_and_hms(9999, 1, 1, 0, 0, 0).unwrap();
832        assert!(matches!(
833            NetworkTraceV1::read_framed(Cursor::new(framed_without_validation(&trace))),
834            Err(NetworkTraceCodecError::Validation(
835                NetworkTraceValidationError::EpochOutOfRange
836            ))
837        ));
838    }
839
840    #[test]
841    fn config_is_off_by_default_and_perturbation_has_no_seed_fallback() {
842        let config = NetworkTraceConfig::default();
843        assert_eq!(config.mode, NetworkTraceMode::Off);
844        assert_eq!(config.network_perturb_seed, None);
845        assert_eq!(config.validate(), Ok(()));
846
847        let replay = NetworkTraceConfig {
848            mode: NetworkTraceMode::Replay,
849            path: Some("trace.net".into()),
850            network_perturb_seed: Some(17),
851        };
852        assert_eq!(replay.network_perturb_seed, Some(17));
853        assert_eq!(replay.validate(), Ok(()));
854
855        let encoded = serde_json::to_string(&replay).unwrap();
856        assert_eq!(
857            serde_json::from_str::<NetworkTraceConfig>(&encoded).unwrap(),
858            replay
859        );
860        assert!(
861            serde_json::from_str::<NetworkTraceConfig>(
862                r#"{"mode":"off","path":null,"network_perturb_seed":null,"unexpected":true}"#
863            )
864            .is_err()
865        );
866    }
867
868    #[test]
869    fn config_rejects_ambiguous_or_record_perturbation_combinations() {
870        assert_eq!(
871            NetworkTraceConfig {
872                path: Some("trace.net".into()),
873                ..NetworkTraceConfig::default()
874            }
875            .validate(),
876            Err(NetworkTraceConfigError::OptionsWhileOff)
877        );
878        assert_eq!(
879            NetworkTraceConfig {
880                mode: NetworkTraceMode::Replay,
881                ..NetworkTraceConfig::default()
882            }
883            .validate(),
884            Err(NetworkTraceConfigError::MissingPath)
885        );
886        assert_eq!(
887            NetworkTraceConfig {
888                mode: NetworkTraceMode::Record,
889                path: Some("trace.net".into()),
890                network_perturb_seed: Some(1),
891            }
892            .validate(),
893            Err(NetworkTraceConfigError::PerturbationDuringRecord)
894        );
895    }
896
897    #[test]
898    fn release_eligibility_uses_the_absolute_global_time_domain() {
899        let mut config = Config {
900            epoch: trace_epoch(),
901            ..Config::default()
902        };
903        // A different scheduler seed must not affect the clock-domain conversion.
904        config.sched_seed = Some(999);
905        let mut global_time = GlobalTime::new(&config);
906        let start = global_time.as_nanos();
907        assert_eq!(valid_trace().epoch_global_time(), Ok(start));
908        let release = NetworkReleaseV1 {
909            not_before_global_time: start + LogicalTime::from_nanos(10),
910            after_transmitted_offset: 3,
911        };
912
913        assert!(!release.is_eligible(global_time.as_nanos(), 3));
914        global_time.add_extra_time(Duration::from_nanos(10));
915        assert_eq!(global_time.as_nanos(), release.not_before_global_time);
916        assert!(!release.is_eligible(global_time.as_nanos(), 2));
917        assert!(release.is_eligible(global_time.as_nanos(), 3));
918    }
919
920    #[test]
921    fn v1_validation_rejects_channels_outside_the_supported_envelope() {
922        let mut trace = valid_trace();
923        trace.channels.push(trace.channels[0].clone());
924        assert_eq!(
925            trace.validate(),
926            Err(NetworkTraceValidationError::ChannelCount(2))
927        );
928
929        let mut trace = valid_trace();
930        let non_socket = OpenFileId::new(DetTid::from_raw(1), 0);
931        trace.channels[0].id = non_socket;
932        for input in &mut trace.inputs {
933            input.channel = non_socket;
934        }
935        for output in &mut trace.outputs {
936            output.channel = non_socket;
937        }
938        assert_eq!(
939            trace.validate(),
940            Err(NetworkTraceValidationError::NonSocketChannelIdentity)
941        );
942
943        let mut trace = valid_trace();
944        trace.channels[0].peer_address = NetworkAddressV1::Inet4 {
945            address: [127, 0, 0, 1],
946            port: 443,
947        };
948        assert_eq!(
949            trace.validate(),
950            Err(NetworkTraceValidationError::UnsupportedPeerAddress)
951        );
952
953        for mapped in [
954            Ipv4Addr::LOCALHOST,
955            Ipv4Addr::UNSPECIFIED,
956            Ipv4Addr::new(224, 0, 0, 1),
957        ] {
958            let mut trace = valid_trace();
959            trace.channels[0].local_address = NetworkAddressV1::Inet6 {
960                address: Ipv6Addr::LOCALHOST.octets(),
961                port: 40_000,
962                flowinfo: 0,
963                scope_id: 0,
964            };
965            trace.channels[0].peer_address = NetworkAddressV1::Inet6 {
966                address: mapped.to_ipv6_mapped().octets(),
967                port: 443,
968                flowinfo: 0,
969                scope_id: 0,
970            };
971            assert_eq!(
972                trace.validate(),
973                Err(NetworkTraceValidationError::UnsupportedPeerAddress),
974                "IPv4-mapped {mapped} escaped the IPv4 policy"
975            );
976        }
977
978        let mut trace = valid_trace();
979        trace.channels[0].created_before_competing_threads = false;
980        assert_eq!(
981            trace.validate(),
982            Err(NetworkTraceValidationError::ScheduleDependentChannelIdentity)
983        );
984    }
985
986    #[test]
987    fn v1_validation_rejects_noncanonical_streams_and_release_conditions() {
988        let mut trace = valid_trace();
989        trace.inputs[0].release.not_before_global_time = LogicalTime::ZERO;
990        assert_eq!(
991            trace.validate(),
992            Err(NetworkTraceValidationError::ReleaseBeforeEpoch)
993        );
994
995        let mut trace = valid_trace();
996        trace.inputs[0].ordinal = 1;
997        assert_eq!(
998            trace.validate(),
999            Err(NetworkTraceValidationError::NonCanonicalOrdinal)
1000        );
1001
1002        let mut trace = valid_trace();
1003        trace.inputs[0].release.after_transmitted_offset = 4;
1004        assert_eq!(
1005            trace.validate(),
1006            Err(NetworkTraceValidationError::UnreachableTransmitWatermark)
1007        );
1008
1009        let mut trace = valid_trace();
1010        trace.outputs[0].stream_offset = 1;
1011        assert_eq!(
1012            trace.validate(),
1013            Err(NetworkTraceValidationError::NonContiguousOutput)
1014        );
1015
1016        let mut trace = valid_trace();
1017        trace.inputs.push(NetworkInputEventV1 {
1018            ordinal: 2,
1019            channel: channel_id(),
1020            release: NetworkReleaseV1 {
1021                not_before_global_time: global_time_after_epoch(21),
1022                after_transmitted_offset: 3,
1023            },
1024            event: NetworkInputKindV1::InboundBytes {
1025                stream_offset: 8,
1026                bytes: b"late".to_vec(),
1027            },
1028        });
1029        assert_eq!(
1030            trace.validate(),
1031            Err(NetworkTraceValidationError::EventAfterTerminal)
1032        );
1033    }
1034}