Skip to main content

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}