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}