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}