Skip to main content

assay_core/replay/bundle/
limits.rs

1//! Replay-bundle-local resource ceilings and their typed refusals.
2//!
3//! ADR-043 ยง1 requires every ingest entrypoint to apply the whole limit set to the source
4//! stream before the input is materialized. The evidence verifier already does this through
5//! its own [`VerifyLimits`], but replay used to have no ceilings at all: `read_bundle_tar_gz`
6//! read every entry with an unbounded `read_to_end`, on top of an unbounded gzip decoder, on
7//! top of an unbounded compressed source.
8//!
9//! The mechanism moves through the shared [`assay_common::limits::LimitReader`] primitive.
10//! The *vocabulary* stays local: a replay bundle is not an evidence bundle, its members are
11//! not events or manifests in the evidence sense, and dragging `VerifyLimits` into
12//! `assay-core` would tell downstream readers that the two contracts agree when they do not.
13//! Callers who really want to share a ceiling still can, by initialising both structs from
14//! the same numbers.
15//!
16//! Refusals travel as [`ReplayIngestError`]. The typed cause on the underlying `io::Error`
17//! is a [`LimitExceeded`], recovered through `LimitExceeded::from_io`; the enum wraps that
18//! with the semantic context the reader had at the time (which member, which path). Callers
19//! match the enum, not the rendered text.
20
21use assay_common::limits::{LimitExceeded, LimitKind};
22
23/// Resource ceilings applied while reading a replay bundle.
24///
25/// Numbers deliberately match the evidence defaults where the two contracts describe the same
26/// resource (compressed source and gzip expansion); the per-member and entry-count values
27/// come from what a replay bundle actually contains: a manifest, a handful of small files,
28/// and cassettes that fit under `max_member_bytes`.
29#[derive(Debug, Clone, Copy)]
30pub struct ReplayLimits {
31    /// Maximum bytes read from the compressed source.
32    pub max_source_bytes: u64,
33    /// Maximum bytes produced by the gzip decoder.
34    pub max_decoded_bytes: u64,
35    /// Maximum bytes of `manifest.json`.
36    pub max_manifest_bytes: u64,
37    /// Maximum bytes of any single non-manifest member.
38    pub max_member_bytes: u64,
39    /// Maximum length in bytes of any entry path.
40    pub max_path_len: usize,
41    /// Maximum number of entries in the archive (including the manifest).
42    pub max_entries: usize,
43    /// Maximum JSON nesting depth accepted in `manifest.json`.
44    ///
45    /// A ceiling on manifest *bytes* says nothing about its shape: a small document can nest
46    /// deeply enough to exhaust the stack in the parser. The evidence side carries the same
47    /// dimension as `max_json_depth`; replay names it locally for the same reason the rest of
48    /// this struct is local.
49    pub max_manifest_json_depth: usize,
50}
51
52impl Default for ReplayLimits {
53    fn default() -> Self {
54        Self {
55            max_source_bytes: 100 * 1024 * 1024,   // 100 MiB compressed
56            max_decoded_bytes: 1024 * 1024 * 1024, // 1 GiB expanded
57            max_manifest_bytes: 10 * 1024 * 1024,  // 10 MiB
58            max_member_bytes: 500 * 1024 * 1024,   // 500 MiB per file (large cassettes)
59            max_path_len: 256,
60            max_entries: 100_000,
61            max_manifest_json_depth: 64,
62        }
63    }
64}
65
66/// Refusal raised by the bounded replay reader. Callers match the variant; the rendered
67/// message is a diagnostic, never a contract.
68#[derive(Debug, thiserror::Error)]
69pub enum ReplayIngestError {
70    #[error("replay bundle exceeded {kind} limit of {limit}")]
71    SourceCeiling { kind: LimitKind, limit: u64 },
72
73    /// Value-free like the others: the member name comes from the archive, so echoing it hands
74    /// an attacker a channel into the diagnostic while telling the reader nothing they can act
75    /// on. The dimension and the ceiling are what distinguishes this from a source refusal.
76    #[error("replay bundle member exceeded {kind} limit of {limit}")]
77    MemberCeiling { kind: LimitKind, limit: u64 },
78
79    /// Deliberately free of the offending value. The path and its length are chosen by the
80    /// archive, and echoing either back gives a reader nothing to act on while handing an
81    /// attacker a channel into the diagnostic.
82    #[error("replay bundle entry path exceeds the configured maximum length of {limit}")]
83    PathTooLong { limit: usize },
84
85    #[error("replay bundle entry count exceeds limit {limit}")]
86    TooManyEntries { limit: usize },
87
88    #[error("replay bundle manifest JSON nesting exceeds the configured maximum depth of {limit}")]
89    ManifestTooDeep { limit: usize },
90}
91
92/// A structural contract violation in the bundle, as opposed to a resource refusal.
93///
94/// Kept out of [`ReplayIngestError`] deliberately. The CLI maps every ingest refusal to
95/// `E_REPLAY_LIMIT_EXCEEDED`, which says a configured budget was exceeded and nothing more:
96/// adjusting the budget or supplying a smaller bundle is a legitimate response, and it is not a
97/// malformed-input finding. It is also not a clean bill of health โ€” the read stopped at the
98/// ceiling, so whatever lies past it was never examined. A duplicate entry is a different kind of
99/// answer: the archive is malformed and no budget will fix it. Folding these into one type would
100/// tell an operator to raise a limit against an archive that must instead be rejected.
101///
102/// Value-free like the ingest refusals. The offending path is chosen by the archive, so echoing
103/// it hands an attacker a channel into the operator's terminal and into every log that ingests
104/// the message, while telling the reader nothing they can act on.
105#[derive(Debug, thiserror::Error, PartialEq, Eq)]
106pub enum ReplayContractError {
107    /// Two entries normalize to the same path. Last-wins is undefined, and silently keeping one
108    /// lets an archive present different bytes to a verifier than to a consumer.
109    #[error("replay bundle contains duplicate entry paths")]
110    DuplicatePath,
111
112    /// A second `manifest.json`. Detected when the second is met โ€” after the first has been read,
113    /// but before the second is read and before it can replace the first โ€” so the manifest
114    /// that is verified is unambiguously the one the archive declared first.
115    #[error("replay bundle contains more than one manifest")]
116    DuplicateManifest,
117}
118
119/// If `err` was produced by a [`LimitReader`](assay_common::limits::LimitReader) that wraps
120/// the compressed source or gzip stream, promote it to a `ReplayIngestError::SourceCeiling`.
121/// Otherwise return `None` so the caller can keep its own classification.
122pub(crate) fn classify_source_ceiling(err: &std::io::Error) -> Option<ReplayIngestError> {
123    let cause = LimitExceeded::from_io(err)?;
124    Some(ReplayIngestError::SourceCeiling {
125        kind: cause.kind,
126        limit: cause.limit,
127    })
128}
129
130/// Same as [`classify_source_ceiling`] but for reads scoped to a single member (the manifest
131/// or an entry body).
132/// A member read sits on top of the decoder, so an overflow surfacing here is not necessarily a
133/// member overflow: the expansion ceiling trips through the same call. Only `MemberBytes` is a
134/// member refusal; every other dimension keeps its own classification, otherwise a decode ceiling
135/// is reported as if a single file were too large.
136pub(crate) fn classify_member_ceiling(err: &std::io::Error) -> Option<ReplayIngestError> {
137    let cause = LimitExceeded::from_io(err)?;
138    // Listed exhaustively rather than with a wildcard. `LimitKind` is deliberately not
139    // `#[non_exhaustive]` so that adding a dimension breaks every consumer at compile time; a
140    // catch-all arm here would defeat that by quietly folding a new ceiling into `SourceCeiling`,
141    // which is the one classification the caller cannot tell apart from a genuine source refusal.
142    Some(match cause.kind {
143        LimitKind::MemberBytes => ReplayIngestError::MemberCeiling {
144            kind: cause.kind,
145            limit: cause.limit,
146        },
147        // A member read sits under the decoder and the source reader, so either of their ceilings
148        // can surface here. Both are source-side refusals from the caller's point of view.
149        LimitKind::SourceBytes | LimitKind::DecodedBytes | LimitKind::LineBytes => {
150            ReplayIngestError::SourceCeiling {
151                kind: cause.kind,
152                limit: cause.limit,
153            }
154        }
155    })
156}
157
158/// Refuse a manifest whose JSON nests deeper than the configured ceiling.
159///
160/// Counts structural depth over the raw bytes rather than parsing first: handing an unbounded
161/// document to `serde_json` is the thing the ceiling exists to prevent, so the check cannot
162/// depend on the parse succeeding.
163pub(crate) fn check_manifest_json_depth(
164    data: &[u8],
165    max_depth: usize,
166) -> Result<(), ReplayIngestError> {
167    let mut depth = 0usize;
168    let mut in_string = false;
169    let mut escaped = false;
170    for &b in data {
171        if in_string {
172            if escaped {
173                escaped = false;
174            } else if b == b'\\' {
175                escaped = true;
176            } else if b == b'"' {
177                in_string = false;
178            }
179            continue;
180        }
181        match b {
182            b'"' => in_string = true,
183            b'{' | b'[' => {
184                depth += 1;
185                if depth > max_depth {
186                    return Err(ReplayIngestError::ManifestTooDeep { limit: max_depth });
187                }
188            }
189            b'}' | b']' => depth = depth.saturating_sub(1),
190            _ => {}
191        }
192    }
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use assay_common::limits::{LimitExceeded, LimitKind};
200
201    fn make_io(kind: LimitKind, limit: u64) -> std::io::Error {
202        std::io::Error::other(LimitExceeded { kind, limit })
203    }
204
205    /// Classification goes through the typed cause, not the message. If it went through the
206    /// message, a reworded diagnostic on `LimitExceeded` would silently reclassify.
207    #[test]
208    fn source_ceiling_is_recovered_from_the_typed_cause() {
209        let io = make_io(LimitKind::DecodedBytes, 1024);
210        match classify_source_ceiling(&io) {
211            Some(ReplayIngestError::SourceCeiling { kind, limit }) => {
212                assert_eq!(kind, LimitKind::DecodedBytes);
213                assert_eq!(limit, 1024);
214            }
215            other => panic!("expected SourceCeiling, got {other:?}"),
216        }
217    }
218
219    /// The dimension and the ceiling travel; the member name does not. It is archive-controlled
220    /// and adds nothing a reader can act on.
221    #[test]
222    fn member_ceiling_carries_the_dimension_and_not_the_member_name() {
223        let io = make_io(LimitKind::MemberBytes, 42);
224        match classify_member_ceiling(&io) {
225            Some(ReplayIngestError::MemberCeiling { kind, limit }) => {
226                assert_eq!(kind, LimitKind::MemberBytes);
227                assert_eq!(limit, 42);
228            }
229            other => panic!("expected MemberCeiling, got {other:?}"),
230        }
231        let rendered = ReplayIngestError::MemberCeiling {
232            kind: LimitKind::MemberBytes,
233            limit: 42,
234        }
235        .to_string();
236        assert!(
237            !rendered.contains('/'),
238            "no archive path may appear: {rendered}"
239        );
240    }
241
242    #[test]
243    fn a_non_ceiling_io_error_is_not_promoted() {
244        let io = std::io::Error::other("something else entirely");
245        assert!(classify_source_ceiling(&io).is_none());
246        assert!(classify_member_ceiling(&io).is_none());
247    }
248}