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 ids per `AccessInvalidatedPayload` list (`user_ids` / `role_ids`).
74/// Aligned with `uptrakit_shared_types::access::bounds::MAX_GRANTS_PER_SUBJECT`
75/// (200); batch mutations touch at most 100 subjects.
76pub const MAX_ACCESS_INVALIDATION_IDS: usize = 200;
77
78/// Maximum byte length of a `DiscoveredSoftware.qualifier` string.
79pub const MAX_DISCOVERED_QUALIFIER_LEN: usize = 256;
80
81/// Maximum number of capabilities in a capability set.
82pub const MAX_CAPABILITIES: usize = 50;
83
84/// Maximum number of MQTT tenants in an assignment message.
85pub const MAX_MQTT_TENANTS: usize = 500;
86
87/// Maximum number of software state items.
88pub const MAX_SOFTWARE_STATE_ITEMS: usize = 2_000;
89
90/// Maximum number of hosts per software state item.
91pub const MAX_SOFTWARE_STATE_HOSTS: usize = 500;
92
93/// Maximum number of host package host states.
94pub const MAX_HOST_PACKAGE_HOST_STATES: usize = 2_000;
95
96/// Maximum number of host metadata entries in a `SoftwareStates` message.
97pub const MAX_MQTT_HOSTS: usize = 2_000;
98
99/// Maximum number of tags per host in a `HostStateMetadata` entry.
100pub const MAX_HOST_TAGS: usize = 100;
101
102/// Maximum number of connectivity updates in a `HostConnectivityUpdated` message.
103pub const MAX_CONNECTIVITY_UPDATES: usize = 500;
104
105/// Maximum number of active MQTT client IDs.
106pub const MAX_ACTIVE_MQTT_CLIENTS: usize = 50_000;
107
108/// Maximum number of capabilities in a single `Register` message.
109///
110/// Bounds the `BTreeSet<Capability>` sent by services, accommodating all known
111/// variants plus a reasonable number of forward-compatibility `Other(String)`
112/// entries.
113pub const MAX_CAPABILITIES_PER_SERVICE: usize = 64;
114
115/// Maximum number of surfaces in a single `SurfaceRegistration` message.
116pub const MAX_SURFACE_MANIFESTS: usize = 50;
117
118/// Maximum number of columns in a `TableColumns` placement or `DataTable` UI.
119pub const MAX_SURFACE_COLUMNS: usize = 50;
120
121/// Maximum number of action ID references in a single surface node.
122pub const MAX_SURFACE_ACTION_REFS: usize = 50;
123
124/// Maximum number of interaction descriptors in a surface registration.
125pub const MAX_SURFACE_ACTIONS: usize = 200;
126
127/// Maximum number of fields in a single form.
128pub const MAX_SURFACE_FIELDS: usize = 100;
129
130/// Maximum number of steps in a wizard.
131pub const MAX_SURFACE_WIZARD_STEPS: usize = 20;
132
133/// Maximum number of options in a select field.
134pub const MAX_SURFACE_SELECT_OPTIONS: usize = 200;
135
136/// Maximum byte length of surface action params JSON.
137pub const MAX_SURFACE_PARAMS_LEN: usize = 65_536;
138
139/// Maximum byte length of surface action response JSON.
140pub const MAX_SURFACE_RESPONSE_LEN: usize = 1_048_576;
141
142/// Maximum nesting depth for JSON values carried in surface payloads.
143pub const MAX_SURFACE_JSON_DEPTH: usize = 32;
144
145/// Maximum number of nodes visited when traversing surface JSON values.
146pub const MAX_SURFACE_JSON_NODES: usize = 20_000;
147
148/// Maximum byte length of plugin config JSON in a `ReportPluginConfig` message.
149pub const MAX_PLUGIN_CONFIG_JSON_LEN: usize = 65_536;
150
151/// Maximum byte length of stdin data in an `UpdateStdinData` message (64 KB).
152///
153/// Base64-encoded bytes written to the process PTY. 64 KB is generous for
154/// interactive input; typical keystrokes are a few bytes each.
155pub const MAX_STDIN_DATA_LEN: usize = 65_536;
156
157/// Maximum byte length of a software item icon URL.
158pub const MAX_ICON_URL_LEN: usize = 2_048;
159
160// ── Pagination limits ───────────────────────────────────────────────────────
161
162/// Maximum number of pages in a single paginated report.
163pub const MAX_REPORT_PAGES: u32 = 50;
164
165/// Number of active hosts processed per page in paginated MQTT software-states delivery.
166pub const STATES_HOST_PAGE_SIZE: u64 = 100;
167
168/// Maximum number of concurrent pending (incomplete) paginated reports per
169/// WebSocket connection. Prevents memory exhaustion from abandoned reports.
170pub const MAX_PENDING_REPORTS_PER_CONNECTION: usize = 10;
171
172/// Total timeout for a paginated report from first page to completion (5 min).
173///
174/// If all pages have not arrived within this window, the report is discarded
175/// and a warning is logged. Already-processed pages committed their DB writes
176/// independently; only the finalization step (e.g. notification) is lost.
177pub const REPORT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
178
179/// Idle timeout after the last page of a paginated report (15 s).
180///
181/// If no new page for the same `report_id` arrives within this window, the
182/// report is considered abandoned. Catches mid-report connection stalls.
183pub const REPORT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
184
185/// Serialized JSON size threshold (768 KB) above which a payload is split into
186/// pages. Well under the 1 MB WebSocket frame limit to leave headroom for the
187/// envelope overhead (protocol_version, seq, trace_context, pagination, type tag).
188pub const PAGINATION_SIZE_THRESHOLD: usize = 786_432;
189
190// ── Trace context limits ────────────────────────────────────────────────────
191
192/// Maximum length of a trace ID (32 hex chars for 128-bit W3C trace ID).
193pub const MAX_TRACE_ID_LEN: usize = 32;
194
195/// Maximum length of a span ID (16 hex chars for 64-bit W3C span ID).
196pub const MAX_SPAN_ID_LEN: usize = 16;
197
198// ── String length limits ──────────────────────────────────────────────────────
199
200/// Maximum length for short strings (identifiers, names, versions).
201pub const MAX_SHORT_STRING_LEN: usize = 1_024;
202
203/// Maximum length for medium strings (hostnames, error messages).
204pub const MAX_MEDIUM_STRING_LEN: usize = 4_096;
205
206/// Maximum length for long strings (PEM certificates, CSRs, release notes).
207pub const MAX_LONG_STRING_LEN: usize = 65_536;
208
209/// Maximum length for output strings (command output, update output).
210/// Matches the 1 MB frame limit — output is already bounded by `MAX_OUTPUT_BYTES`
211/// in `agent-core/src/update.rs`.
212pub const MAX_OUTPUT_STRING_LEN: usize = 1_048_576;
213
214/// Maximum byte length of a config test output string.
215pub const MAX_CONFIG_TEST_OUTPUT_LEN: usize = 65_536;
216
217/// Maximum number of assets in a `ReleaseInfo` message.
218pub const MAX_RELEASE_ASSETS: usize = 500;
219
220/// Maximum number of entries in a `ServiceConfigDelivery` or `ServiceConfigUpdated` message.
221pub const MAX_SERVICE_CONFIG_ENTRIES: usize = 1_000_000;
222
223/// Maximum byte length of a service config value (serialized JSON).
224pub const MAX_SERVICE_CONFIG_VALUE_LEN: usize = 65_536;
225
226/// Maximum number of config keys in a single `WorkloadClaim` message.
227pub const MAX_WORKLOAD_CLAIM_KEYS: usize = 100_000;
228
229/// Expected byte length of a SHA-256 hex digest string (64 hex characters).
230pub const SHA256_DIGEST_LEN: usize = 64;
231
232// ── Helper functions ──────────────────────────────────────────────────────────
233
234/// Check that a `Vec` does not exceed the given length limit.
235pub fn check_vec_len<T>(
236    items: &[T],
237    max: usize,
238    field: &'static str,
239) -> Result<(), WireValidationError> {
240    if items.len() > max {
241        return Err(WireValidationError {
242            field,
243            message: format!("collection has {} items, max {max}", items.len()),
244        });
245    }
246    Ok(())
247}
248
249/// Check that a `String` does not exceed the given byte length limit.
250pub fn check_string_len(
251    s: &str,
252    max: usize,
253    field: &'static str,
254) -> Result<(), WireValidationError> {
255    if s.len() > max {
256        return Err(WireValidationError {
257            field,
258            message: format!("string is {} bytes, max {max}", s.len()),
259        });
260    }
261    Ok(())
262}
263
264/// Check that a `BTreeMap` does not exceed the given length limit.
265pub fn check_map_len<K, V>(
266    items: &std::collections::BTreeMap<K, V>,
267    max: usize,
268    field: &'static str,
269) -> Result<(), WireValidationError> {
270    if items.len() > max {
271        return Err(WireValidationError {
272            field,
273            message: format!("map has {} entries, max {max}", items.len()),
274        });
275    }
276    Ok(())
277}
278
279/// Check that a `BTreeSet` does not exceed the given length limit.
280pub fn check_set_len<T>(
281    items: &std::collections::BTreeSet<T>,
282    max: usize,
283    field: &'static str,
284) -> Result<(), WireValidationError> {
285    if items.len() > max {
286        return Err(WireValidationError {
287            field,
288            message: format!("set has {} items, max {max}", items.len()),
289        });
290    }
291    Ok(())
292}
293
294/// Check that an `Option<String>` does not exceed the given byte length limit.
295pub fn check_opt_string_len(
296    s: &Option<String>,
297    max: usize,
298    field: &'static str,
299) -> Result<(), WireValidationError> {
300    if let Some(s) = s {
301        check_string_len(s, max, field)?;
302    }
303    Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308    #![expect(
309        clippy::assertions_on_result_states,
310        reason = "test assertions — is_ok/is_err provides readable failure messages"
311    )]
312    use super::*;
313
314    #[test]
315    fn check_vec_len_at_limit() {
316        let items = vec![0u8; MAX_REPORT_HOSTS];
317        assert!(check_vec_len(&items, MAX_REPORT_HOSTS, "test").is_ok());
318    }
319
320    #[test]
321    fn check_vec_len_over_limit() {
322        let items = vec![0u8; MAX_REPORT_HOSTS + 1];
323        let err = check_vec_len(&items, MAX_REPORT_HOSTS, "test").unwrap_err();
324        assert_eq!(err.field, "test");
325        assert!(err.message.contains("501"));
326    }
327
328    #[test]
329    fn check_vec_len_empty() {
330        let items: Vec<u8> = vec![];
331        assert!(check_vec_len(&items, MAX_REPORT_HOSTS, "test").is_ok());
332    }
333
334    #[test]
335    fn check_string_len_at_limit() {
336        let s = "a".repeat(MAX_SHORT_STRING_LEN);
337        assert!(check_string_len(&s, MAX_SHORT_STRING_LEN, "test").is_ok());
338    }
339
340    #[test]
341    fn check_string_len_over_limit() {
342        let s = "a".repeat(MAX_SHORT_STRING_LEN + 1);
343        let err = check_string_len(&s, MAX_SHORT_STRING_LEN, "test").unwrap_err();
344        assert_eq!(err.field, "test");
345        assert!(err.message.contains("1025"));
346    }
347
348    #[test]
349    fn check_opt_string_len_none() {
350        assert!(check_opt_string_len(&None, MAX_SHORT_STRING_LEN, "test").is_ok());
351    }
352
353    #[test]
354    fn check_opt_string_len_some_over() {
355        let s = Some("a".repeat(MAX_SHORT_STRING_LEN + 1));
356        assert!(check_opt_string_len(&s, MAX_SHORT_STRING_LEN, "test").is_err());
357    }
358
359    #[test]
360    fn wire_validation_error_display() {
361        let err = WireValidationError {
362            field: "hosts",
363            message: "too many".to_string(),
364        };
365        let display = err.to_string();
366        assert!(display.contains("hosts"));
367        assert!(display.contains("too many"));
368    }
369}