uptrakit_wire/limits.rs
1//! Per-field and per-collection size limits for wire protocol payloads.
2//!
3//! Post-deserialization validation prevents O(N) or O(N*M) processing attacks
4//! within the 1 MB WebSocket frame limit. All limits are set above real-world
5//! maximums with generous headroom to avoid breaking legitimate payloads.
6//!
7//! ## Design decision: post-deserialization validation
8//!
9//! We use a `WireValidate` trait (not custom serde deserializers) because:
10//! - Custom deserializers are verbose and fragile for dozens of fields
11//! - The 1 MB frame limit already caps total memory; the concern is processing cost
12//! - Consistent with the existing `Validate` pattern in `web-api-types`
13//! - Trivially backward-compatible (limits set far above real-world maximums)
14
15use std::fmt;
16
17/// Error returned when a wire payload field exceeds its size limit.
18#[derive(Debug, Clone)]
19pub struct WireValidationError {
20 /// The field path that failed validation (e.g. `"hosts"`, `"results[0].error"`).
21 pub field: &'static str,
22 /// Human-readable description of the violation.
23 pub message: String,
24}
25
26impl fmt::Display for WireValidationError {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 write!(f, "wire validation error: {}: {}", self.field, self.message)
29 }
30}
31
32impl std::error::Error for WireValidationError {}
33
34/// Trait for post-deserialization validation of wire protocol payloads.
35///
36/// Implementors check that all `Vec<T>` and `String` fields are within
37/// bounds. Returns `Ok(())` when all fields pass, or the first violation
38/// encountered.
39pub trait WireValidate {
40 /// Validate that all fields are within wire protocol size limits.
41 fn wire_validate(&self) -> Result<(), WireValidationError>;
42}
43
44// ── Collection size limits ────────────────────────────────────────────────────
45
46/// Maximum number of hosts in a `ReportHosts` message.
47pub const MAX_REPORT_HOSTS: usize = 500;
48
49/// Maximum number of version check assignments in a single message.
50pub const MAX_VERSION_CHECK_ASSIGNMENTS: usize = 2_000;
51
52/// Maximum number of version check results in a single message.
53pub const MAX_VERSION_CHECK_RESULTS: usize = 2_000;
54
55/// Maximum number of pre/post-update hook plugins in an update message.
56pub const MAX_UPDATE_HOOKS: usize = 50;
57
58/// Maximum number of packages in a batch update.
59pub const MAX_BATCH_UPDATES: usize = 500;
60
61/// Maximum number of results in a batch update result.
62pub const MAX_BATCH_UPDATE_RESULTS: usize = 500;
63
64/// Maximum number of discovery plugins in a single message.
65pub const MAX_DISCOVERY_PLUGINS: usize = 50;
66
67/// Maximum number of discovery plugin results in a single message.
68pub const MAX_DISCOVERY_PLUGIN_RESULTS: usize = 50;
69
70/// Maximum number of discoveries per plugin result.
71pub const MAX_DISCOVERIES_PER_PLUGIN: usize = 1_000;
72
73/// Maximum byte length of a `DiscoveredSoftware.qualifier` string.
74pub const MAX_DISCOVERED_QUALIFIER_LEN: usize = 256;
75
76/// Maximum number of capabilities in a capability set.
77pub const MAX_CAPABILITIES: usize = 50;
78
79/// Maximum number of MQTT tenants in an assignment message.
80pub const MAX_MQTT_TENANTS: usize = 500;
81
82/// Maximum number of software state items.
83pub const MAX_SOFTWARE_STATE_ITEMS: usize = 2_000;
84
85/// Maximum number of hosts per software state item.
86pub const MAX_SOFTWARE_STATE_HOSTS: usize = 500;
87
88/// Maximum number of host package host states.
89pub const MAX_HOST_PACKAGE_HOST_STATES: usize = 2_000;
90
91/// Maximum number of host metadata entries in a `SoftwareStates` message.
92pub const MAX_MQTT_HOSTS: usize = 2_000;
93
94/// Maximum number of tags per host in a `HostStateMetadata` entry.
95pub const MAX_HOST_TAGS: usize = 100;
96
97/// Maximum number of connectivity updates in a `HostConnectivityUpdated` message.
98pub const MAX_CONNECTIVITY_UPDATES: usize = 500;
99
100/// Maximum number of active MQTT client IDs.
101pub const MAX_ACTIVE_MQTT_CLIENTS: usize = 50_000;
102
103/// Maximum number of capabilities in a single `Register` message.
104///
105/// Bounds the `BTreeSet<Capability>` sent by services, accommodating all known
106/// variants plus a reasonable number of forward-compatibility `Other(String)`
107/// entries.
108pub const MAX_CAPABILITIES_PER_SERVICE: usize = 64;
109
110/// Maximum number of surfaces in a single `SurfaceRegistration` message.
111pub const MAX_SURFACE_MANIFESTS: usize = 50;
112
113/// Maximum number of columns in a `TableColumns` placement or `DataTable` UI.
114pub const MAX_SURFACE_COLUMNS: usize = 50;
115
116/// Maximum number of action ID references in a single surface node.
117pub const MAX_SURFACE_ACTION_REFS: usize = 50;
118
119/// Maximum number of interaction descriptors in a surface registration.
120pub const MAX_SURFACE_ACTIONS: usize = 200;
121
122/// Maximum number of fields in a single form.
123pub const MAX_SURFACE_FIELDS: usize = 100;
124
125/// Maximum number of steps in a wizard.
126pub const MAX_SURFACE_WIZARD_STEPS: usize = 20;
127
128/// Maximum number of options in a select field.
129pub const MAX_SURFACE_SELECT_OPTIONS: usize = 200;
130
131/// Maximum byte length of surface action params JSON.
132pub const MAX_SURFACE_PARAMS_LEN: usize = 65_536;
133
134/// Maximum byte length of surface action response JSON.
135pub const MAX_SURFACE_RESPONSE_LEN: usize = 1_048_576;
136
137/// Maximum nesting depth for JSON values carried in surface payloads.
138pub const MAX_SURFACE_JSON_DEPTH: usize = 32;
139
140/// Maximum number of nodes visited when traversing surface JSON values.
141pub const MAX_SURFACE_JSON_NODES: usize = 20_000;
142
143/// Maximum byte length of plugin config JSON in a `ReportPluginConfig` message.
144pub const MAX_PLUGIN_CONFIG_JSON_LEN: usize = 65_536;
145
146/// Maximum byte length of stdin data in an `UpdateStdinData` message (64 KB).
147///
148/// Base64-encoded bytes written to the process PTY. 64 KB is generous for
149/// interactive input; typical keystrokes are a few bytes each.
150pub const MAX_STDIN_DATA_LEN: usize = 65_536;
151
152/// Maximum byte length of a software item icon URL.
153pub const MAX_ICON_URL_LEN: usize = 2_048;
154
155// ── Pagination limits ───────────────────────────────────────────────────────
156
157/// Maximum number of pages in a single paginated report.
158pub const MAX_REPORT_PAGES: u32 = 50;
159
160/// Number of active hosts processed per page in paginated MQTT software-states delivery.
161pub const STATES_HOST_PAGE_SIZE: u64 = 100;
162
163/// Maximum number of concurrent pending (incomplete) paginated reports per
164/// WebSocket connection. Prevents memory exhaustion from abandoned reports.
165pub const MAX_PENDING_REPORTS_PER_CONNECTION: usize = 10;
166
167/// Total timeout for a paginated report from first page to completion (5 min).
168///
169/// If all pages have not arrived within this window, the report is discarded
170/// and a warning is logged. Already-processed pages committed their DB writes
171/// independently; only the finalization step (e.g. notification) is lost.
172pub const REPORT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
173
174/// Idle timeout after the last page of a paginated report (15 s).
175///
176/// If no new page for the same `report_id` arrives within this window, the
177/// report is considered abandoned. Catches mid-report connection stalls.
178pub const REPORT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
179
180/// Serialized JSON size threshold (768 KB) above which a payload is split into
181/// pages. Well under the 1 MB WebSocket frame limit to leave headroom for the
182/// envelope overhead (protocol_version, seq, trace_context, pagination, type tag).
183pub const PAGINATION_SIZE_THRESHOLD: usize = 786_432;
184
185// ── Trace context limits ────────────────────────────────────────────────────
186
187/// Maximum length of a trace ID (32 hex chars for 128-bit W3C trace ID).
188pub const MAX_TRACE_ID_LEN: usize = 32;
189
190/// Maximum length of a span ID (16 hex chars for 64-bit W3C span ID).
191pub const MAX_SPAN_ID_LEN: usize = 16;
192
193// ── String length limits ──────────────────────────────────────────────────────
194
195/// Maximum length for short strings (identifiers, names, versions).
196pub const MAX_SHORT_STRING_LEN: usize = 1_024;
197
198/// Maximum length for medium strings (hostnames, error messages).
199pub const MAX_MEDIUM_STRING_LEN: usize = 4_096;
200
201/// Maximum length for long strings (PEM certificates, CSRs, release notes).
202pub const MAX_LONG_STRING_LEN: usize = 65_536;
203
204/// Maximum length for output strings (command output, update output).
205/// Matches the 1 MB frame limit — output is already bounded by `MAX_OUTPUT_BYTES`
206/// in `agent-core/src/update.rs`.
207pub const MAX_OUTPUT_STRING_LEN: usize = 1_048_576;
208
209/// Maximum byte length of a config test output string.
210pub const MAX_CONFIG_TEST_OUTPUT_LEN: usize = 65_536;
211
212/// Maximum number of assets in a `ReleaseInfo` message.
213pub const MAX_RELEASE_ASSETS: usize = 500;
214
215/// Maximum number of entries in a `ServiceConfigDelivery` or `ServiceConfigUpdated` message.
216pub const MAX_SERVICE_CONFIG_ENTRIES: usize = 1_000_000;
217
218/// Maximum byte length of a service config value (serialized JSON).
219pub const MAX_SERVICE_CONFIG_VALUE_LEN: usize = 65_536;
220
221/// Maximum number of config keys in a single `WorkloadClaim` message.
222pub const MAX_WORKLOAD_CLAIM_KEYS: usize = 100_000;
223
224/// Expected byte length of a SHA-256 hex digest string (64 hex characters).
225pub const SHA256_DIGEST_LEN: usize = 64;
226
227// ── Helper functions ──────────────────────────────────────────────────────────
228
229/// Check that a `Vec` does not exceed the given length limit.
230pub fn check_vec_len<T>(
231 items: &[T],
232 max: usize,
233 field: &'static str,
234) -> Result<(), WireValidationError> {
235 if items.len() > max {
236 return Err(WireValidationError {
237 field,
238 message: format!("collection has {} items, max {max}", items.len()),
239 });
240 }
241 Ok(())
242}
243
244/// Check that a `String` does not exceed the given byte length limit.
245pub fn check_string_len(
246 s: &str,
247 max: usize,
248 field: &'static str,
249) -> Result<(), WireValidationError> {
250 if s.len() > max {
251 return Err(WireValidationError {
252 field,
253 message: format!("string is {} bytes, max {max}", s.len()),
254 });
255 }
256 Ok(())
257}
258
259/// Check that a `BTreeMap` does not exceed the given length limit.
260pub fn check_map_len<K, V>(
261 items: &std::collections::BTreeMap<K, V>,
262 max: usize,
263 field: &'static str,
264) -> Result<(), WireValidationError> {
265 if items.len() > max {
266 return Err(WireValidationError {
267 field,
268 message: format!("map has {} entries, max {max}", items.len()),
269 });
270 }
271 Ok(())
272}
273
274/// Check that a `BTreeSet` does not exceed the given length limit.
275pub fn check_set_len<T>(
276 items: &std::collections::BTreeSet<T>,
277 max: usize,
278 field: &'static str,
279) -> Result<(), WireValidationError> {
280 if items.len() > max {
281 return Err(WireValidationError {
282 field,
283 message: format!("set has {} items, max {max}", items.len()),
284 });
285 }
286 Ok(())
287}
288
289/// Check that an `Option<String>` does not exceed the given byte length limit.
290pub fn check_opt_string_len(
291 s: &Option<String>,
292 max: usize,
293 field: &'static str,
294) -> Result<(), WireValidationError> {
295 if let Some(s) = s {
296 check_string_len(s, max, field)?;
297 }
298 Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303 #![expect(
304 clippy::assertions_on_result_states,
305 reason = "test assertions — is_ok/is_err provides readable failure messages"
306 )]
307 use super::*;
308
309 #[test]
310 fn check_vec_len_at_limit() {
311 let items = vec![0u8; MAX_REPORT_HOSTS];
312 assert!(check_vec_len(&items, MAX_REPORT_HOSTS, "test").is_ok());
313 }
314
315 #[test]
316 fn check_vec_len_over_limit() {
317 let items = vec![0u8; MAX_REPORT_HOSTS + 1];
318 let err = check_vec_len(&items, MAX_REPORT_HOSTS, "test").unwrap_err();
319 assert_eq!(err.field, "test");
320 assert!(err.message.contains("501"));
321 }
322
323 #[test]
324 fn check_vec_len_empty() {
325 let items: Vec<u8> = vec![];
326 assert!(check_vec_len(&items, MAX_REPORT_HOSTS, "test").is_ok());
327 }
328
329 #[test]
330 fn check_string_len_at_limit() {
331 let s = "a".repeat(MAX_SHORT_STRING_LEN);
332 assert!(check_string_len(&s, MAX_SHORT_STRING_LEN, "test").is_ok());
333 }
334
335 #[test]
336 fn check_string_len_over_limit() {
337 let s = "a".repeat(MAX_SHORT_STRING_LEN + 1);
338 let err = check_string_len(&s, MAX_SHORT_STRING_LEN, "test").unwrap_err();
339 assert_eq!(err.field, "test");
340 assert!(err.message.contains("1025"));
341 }
342
343 #[test]
344 fn check_opt_string_len_none() {
345 assert!(check_opt_string_len(&None, MAX_SHORT_STRING_LEN, "test").is_ok());
346 }
347
348 #[test]
349 fn check_opt_string_len_some_over() {
350 let s = Some("a".repeat(MAX_SHORT_STRING_LEN + 1));
351 assert!(check_opt_string_len(&s, MAX_SHORT_STRING_LEN, "test").is_err());
352 }
353
354 #[test]
355 fn wire_validation_error_display() {
356 let err = WireValidationError {
357 field: "hosts",
358 message: "too many".to_string(),
359 };
360 let display = err.to_string();
361 assert!(display.contains("hosts"));
362 assert!(display.contains("too many"));
363 }
364}