Skip to main content

cadmpeg_core/decode/
policy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Decode modes, resource ceilings, and input-proportional allowances.
3
4#[cfg(feature = "schema")]
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8const MIB: u64 = 1024 * 1024;
9const GIB: u64 = 1024 * MIB;
10
11/// Whether a successful decode may report mandatory transfer losses.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(JsonSchema))]
14#[serde(rename_all = "snake_case")]
15#[serde(deny_unknown_fields)]
16pub enum DecodeMode {
17    /// Reject a completed decode that reports a mandatory transfer loss.
18    Strict,
19    /// Return the decoded result with its loss report.
20    #[default]
21    Salvage,
22}
23
24/// Absolute resource ceilings.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(JsonSchema))]
27#[serde(deny_unknown_fields)]
28pub struct ResourceLimits {
29    /// Maximum physical input bytes read at the root.
30    pub max_input_bytes: u64,
31    /// Maximum cumulative decompressed bytes across all expansions.
32    pub max_decompressed_bytes_total: u64,
33    /// Maximum decompressed bytes produced by any single expansion.
34    pub max_decompressed_bytes_per_expand: u64,
35    /// Maximum bytes decoded output retains: native record text, arena
36    /// buffers, and retained copies of source bytes.
37    #[serde(default = "default_max_retained_bytes")]
38    pub max_retained_bytes: u64,
39    /// Maximum admitted semantic or native entities.
40    #[serde(default = "default_max_entities")]
41    pub max_entities: u64,
42    /// Maximum active recursive nesting depth.
43    #[serde(default = "default_max_recursion_depth")]
44    pub max_recursion_depth: u64,
45}
46
47impl ResourceLimits {
48    /// Generous ceilings for interactive desktop use; the default profile.
49    pub const fn desktop() -> Self {
50        Self {
51            max_input_bytes: 4 * GIB,
52            max_decompressed_bytes_total: 8 * GIB,
53            max_decompressed_bytes_per_expand: 2 * GIB,
54            max_retained_bytes: 4 * GIB,
55            max_entities: 64_000_000,
56            max_recursion_depth: 256,
57        }
58    }
59
60    /// Tight ceilings for unattended service use.
61    pub const fn service() -> Self {
62        Self {
63            max_input_bytes: 256 * MIB,
64            max_decompressed_bytes_total: GIB,
65            max_decompressed_bytes_per_expand: 256 * MIB,
66            max_retained_bytes: 512 * MIB,
67            max_entities: 8_000_000,
68            max_recursion_depth: 128,
69        }
70    }
71}
72
73impl Default for ResourceLimits {
74    fn default() -> Self {
75        Self::desktop()
76    }
77}
78
79/// Decode mode and resource ceilings.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[cfg_attr(feature = "schema", derive(JsonSchema))]
82#[serde(deny_unknown_fields)]
83pub struct DecodePolicy {
84    /// How mandatory transfer losses are handled.
85    pub mode: DecodeMode,
86    /// The absolute ceilings.
87    pub limits: ResourceLimits,
88}
89
90impl DecodePolicy {
91    /// The default desktop profile in salvage mode.
92    pub const fn desktop() -> Self {
93        Self {
94            mode: DecodeMode::Salvage,
95            limits: ResourceLimits::desktop(),
96        }
97    }
98
99    /// The tight service profile in salvage mode.
100    pub const fn service() -> Self {
101        Self {
102            mode: DecodeMode::Salvage,
103            limits: ResourceLimits::service(),
104        }
105    }
106}
107
108impl Default for DecodePolicy {
109    fn default() -> Self {
110        Self::desktop()
111    }
112}
113
114/// Resource options for container inspection.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
116#[cfg_attr(feature = "schema", derive(JsonSchema))]
117#[serde(deny_unknown_fields)]
118pub struct InspectOptions {
119    /// The absolute ceilings applied during inspection.
120    pub limits: ResourceLimits,
121}
122
123pub(super) const DECOMPRESSED_TOTAL_BASE: u64 = 16 * MIB;
124pub(super) const DECOMPRESSED_TOTAL_PER_INPUT_BYTE: u64 = 1000;
125pub(super) const DECOMPRESSED_PER_EXPAND_BASE: u64 = 16 * MIB;
126pub(super) const DECOMPRESSED_PER_EXPAND_PER_INPUT_BYTE: u64 = 256;
127pub(super) const RETAINED_BASE: u64 = 16 * MIB;
128pub(super) const RETAINED_PER_INPUT_BYTE: u64 = 1000;
129
130const fn default_max_retained_bytes() -> u64 {
131    ResourceLimits::desktop().max_retained_bytes
132}
133
134const fn default_max_entities() -> u64 {
135    ResourceLimits::desktop().max_entities
136}
137
138const fn default_max_recursion_depth() -> u64 {
139    ResourceLimits::desktop().max_recursion_depth
140}
141
142#[cfg(test)]
143mod tests {
144    #![allow(clippy::unwrap_used)]
145
146    use super::{DecodePolicy, ResourceLimits, GIB, MIB};
147
148    #[test]
149    fn desktop_ceilings_are_explicit() {
150        let d = ResourceLimits::desktop();
151        assert_eq!(d.max_input_bytes, 4 * GIB);
152        assert_eq!(d.max_decompressed_bytes_total, 8 * GIB);
153        assert_eq!(d.max_decompressed_bytes_per_expand, 2 * GIB);
154        assert_eq!(d.max_retained_bytes, 4 * GIB);
155        assert_eq!(d.max_entities, 64_000_000);
156        assert_eq!(d.max_recursion_depth, 256);
157    }
158
159    #[test]
160    fn service_ceilings_are_explicit() {
161        let s = ResourceLimits::service();
162        assert_eq!(s.max_input_bytes, 256 * MIB);
163        assert_eq!(s.max_decompressed_bytes_total, GIB);
164        assert_eq!(s.max_decompressed_bytes_per_expand, 256 * MIB);
165        assert_eq!(s.max_retained_bytes, 512 * MIB);
166        assert_eq!(s.max_entities, 8_000_000);
167        assert_eq!(s.max_recursion_depth, 128);
168    }
169
170    #[test]
171    fn added_limits_default_when_deserializing_old_policy() {
172        let json = r#"{
173            "max_input_bytes": 1,
174            "max_decompressed_bytes_total": 2,
175            "max_decompressed_bytes_per_expand": 3
176        }"#;
177        let limits: ResourceLimits = serde_json::from_str(json).unwrap();
178        let desktop = ResourceLimits::desktop();
179        assert_eq!(limits.max_retained_bytes, desktop.max_retained_bytes);
180        assert_eq!(limits.max_entities, desktop.max_entities);
181        assert_eq!(limits.max_recursion_depth, desktop.max_recursion_depth);
182    }
183
184    #[test]
185    fn removed_limits_are_rejected_by_name() {
186        for field in [
187            "max_materialized_bytes",
188            "max_collection_items",
189            "max_work_units",
190        ] {
191            let limits = format!(
192                r#"{{"max_input_bytes":1,"max_decompressed_bytes_total":2,"max_decompressed_bytes_per_expand":3,"{field}":4}}"#
193            );
194            let policy = format!(r#"{{"mode":"salvage","limits":{limits}}}"#);
195            let expected = format!("unknown field `{field}`");
196            let error = serde_json::from_str::<ResourceLimits>(&limits).unwrap_err();
197            assert!(error.to_string().contains(&expected), "{error}");
198            let error = serde_json::from_str::<DecodePolicy>(&policy).unwrap_err();
199            assert!(error.to_string().contains(&expected), "{error}");
200        }
201    }
202}