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)]
69#[non_exhaustive]
70pub enum ReplayIngestError {
71    #[error("replay bundle exceeded {kind} limit of {limit}")]
72    SourceCeiling { kind: LimitKind, limit: u64 },
73
74    /// Value-free like the others: the member name comes from the archive, so echoing it hands
75    /// an attacker a channel into the diagnostic while telling the reader nothing they can act
76    /// on. The dimension and the ceiling are what distinguishes this from a source refusal.
77    #[error("replay bundle member exceeded {kind} limit of {limit}")]
78    MemberCeiling { kind: LimitKind, limit: u64 },
79
80    /// Deliberately free of the offending value. The path and its length are chosen by the
81    /// archive, and echoing either back gives a reader nothing to act on while handing an
82    /// attacker a channel into the diagnostic.
83    #[error("replay bundle entry path exceeds the configured maximum length of {limit}")]
84    PathTooLong { limit: usize },
85
86    #[error("replay bundle entry count exceeds limit {limit}")]
87    TooManyEntries { limit: usize },
88
89    #[error("replay bundle manifest JSON nesting exceeds the configured maximum depth of {limit}")]
90    ManifestTooDeep { limit: usize },
91}
92
93/// A structural contract violation in the bundle, as opposed to a resource refusal.
94///
95/// Kept out of [`ReplayIngestError`] deliberately. The CLI maps every ingest refusal to
96/// `E_REPLAY_LIMIT_EXCEEDED`, which says a configured budget was exceeded and nothing more:
97/// adjusting the budget or supplying a smaller bundle is a legitimate response, and it is not a
98/// malformed-input finding. It is also not a clean bill of health โ€” the read stopped at the
99/// ceiling, so whatever lies past it was never examined. A duplicate entry is a different kind of
100/// answer: the archive is malformed and no budget will fix it. Folding these into one type would
101/// tell an operator to raise a limit against an archive that must instead be rejected.
102///
103/// Value-free like the ingest refusals. The offending path is chosen by the archive, so echoing
104/// it hands an attacker a channel into the operator's terminal and into every log that ingests
105/// the message, while telling the reader nothing they can act on.
106#[derive(Debug, thiserror::Error, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum ReplayContractError {
109    /// Two entries normalize to the same path. Last-wins is undefined, and silently keeping one
110    /// lets an archive present different bytes to a verifier than to a consumer.
111    #[error("replay bundle contains duplicate entry paths")]
112    DuplicatePath,
113
114    /// A second `manifest.json`. Detected when the second is met โ€” after the first has been read,
115    /// but before the second is read and before it can replace the first โ€” so the manifest
116    /// that is verified is unambiguously the one the archive declared first.
117    #[error("replay bundle contains more than one manifest")]
118    DuplicateManifest,
119}
120
121/// If `err` was produced by a [`LimitReader`](assay_common::limits::LimitReader) that wraps
122/// the compressed source or gzip stream, promote it to a `ReplayIngestError::SourceCeiling`.
123/// Otherwise return `None` so the caller can keep its own classification.
124pub(crate) fn classify_source_ceiling(err: &std::io::Error) -> Option<ReplayIngestError> {
125    let cause = LimitExceeded::from_io(err)?;
126    Some(ReplayIngestError::SourceCeiling {
127        kind: cause.kind,
128        limit: cause.limit,
129    })
130}
131
132/// Same as [`classify_source_ceiling`] but for reads scoped to a single member (the manifest
133/// or an entry body).
134/// A member read sits on top of the decoder, so an overflow surfacing here is not necessarily a
135/// member overflow: the expansion ceiling trips through the same call. Only `MemberBytes` is a
136/// member refusal; every other dimension keeps its own classification, otherwise a decode ceiling
137/// is reported as if a single file were too large.
138pub(crate) fn classify_member_ceiling(err: &std::io::Error) -> Option<ReplayIngestError> {
139    let cause = LimitExceeded::from_io(err)?;
140    // Listed exhaustively rather than with a wildcard. `LimitKind` is deliberately not
141    // `#[non_exhaustive]` so that adding a dimension breaks every consumer at compile time; a
142    // catch-all arm here would defeat that by quietly folding a new ceiling into `SourceCeiling`,
143    // which is the one classification the caller cannot tell apart from a genuine source refusal.
144    Some(match cause.kind {
145        LimitKind::MemberBytes => ReplayIngestError::MemberCeiling {
146            kind: cause.kind,
147            limit: cause.limit,
148        },
149        // A member read sits under the decoder and the source reader, so either of their ceilings
150        // can surface here. Both are source-side refusals from the caller's point of view.
151        LimitKind::SourceBytes | LimitKind::DecodedBytes | LimitKind::LineBytes => {
152            ReplayIngestError::SourceCeiling {
153                kind: cause.kind,
154                limit: cause.limit,
155            }
156        }
157    })
158}
159
160/// Refuse a manifest whose JSON nests deeper than the configured ceiling.
161///
162/// Counts structural depth over the raw bytes rather than parsing first: handing an unbounded
163/// document to `serde_json` is the thing the ceiling exists to prevent, so the check cannot
164/// depend on the parse succeeding.
165pub(crate) fn check_manifest_json_depth(
166    data: &[u8],
167    max_depth: usize,
168) -> Result<(), ReplayIngestError> {
169    let mut depth = 0usize;
170    let mut in_string = false;
171    let mut escaped = false;
172    for &b in data {
173        if in_string {
174            if escaped {
175                escaped = false;
176            } else if b == b'\\' {
177                escaped = true;
178            } else if b == b'"' {
179                in_string = false;
180            }
181            continue;
182        }
183        match b {
184            b'"' => in_string = true,
185            b'{' | b'[' => {
186                depth += 1;
187                if depth > max_depth {
188                    return Err(ReplayIngestError::ManifestTooDeep { limit: max_depth });
189                }
190            }
191            b'}' | b']' => depth = depth.saturating_sub(1),
192            _ => {}
193        }
194    }
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use assay_common::limits::{LimitExceeded, LimitKind};
202
203    fn make_io(kind: LimitKind, limit: u64) -> std::io::Error {
204        std::io::Error::other(LimitExceeded { kind, limit })
205    }
206
207    /// Classification goes through the typed cause, not the message. If it went through the
208    /// message, a reworded diagnostic on `LimitExceeded` would silently reclassify.
209    #[test]
210    fn source_ceiling_is_recovered_from_the_typed_cause() {
211        let io = make_io(LimitKind::DecodedBytes, 1024);
212        match classify_source_ceiling(&io) {
213            Some(ReplayIngestError::SourceCeiling { kind, limit }) => {
214                assert_eq!(kind, LimitKind::DecodedBytes);
215                assert_eq!(limit, 1024);
216            }
217            other => panic!("expected SourceCeiling, got {other:?}"),
218        }
219    }
220
221    /// The dimension and the ceiling travel; the member name does not. It is archive-controlled
222    /// and adds nothing a reader can act on.
223    #[test]
224    fn member_ceiling_carries_the_dimension_and_not_the_member_name() {
225        let io = make_io(LimitKind::MemberBytes, 42);
226        match classify_member_ceiling(&io) {
227            Some(ReplayIngestError::MemberCeiling { kind, limit }) => {
228                assert_eq!(kind, LimitKind::MemberBytes);
229                assert_eq!(limit, 42);
230            }
231            other => panic!("expected MemberCeiling, got {other:?}"),
232        }
233        let rendered = ReplayIngestError::MemberCeiling {
234            kind: LimitKind::MemberBytes,
235            limit: 42,
236        }
237        .to_string();
238        assert!(
239            !rendered.contains('/'),
240            "no archive path may appear: {rendered}"
241        );
242    }
243
244    #[test]
245    fn a_non_ceiling_io_error_is_not_promoted() {
246        let io = std::io::Error::other("something else entirely");
247        assert!(classify_source_ceiling(&io).is_none());
248        assert!(classify_member_ceiling(&io).is_none());
249    }
250}