cadmpeg-core 0.6.7

Bounded byte decoding primitives and codec resource policy for cadmpeg.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Decode modes, resource ceilings, and input-proportional allowances.

#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * MIB;

/// Whether a successful decode may report mandatory transfer losses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "snake_case")]
#[serde(deny_unknown_fields)]
pub enum DecodeMode {
    /// Reject a completed decode that reports a mandatory transfer loss.
    Strict,
    /// Return the decoded result with its loss report.
    #[default]
    Salvage,
}

/// Absolute resource ceilings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ResourceLimits {
    /// Maximum physical input bytes read at the root.
    pub max_input_bytes: u64,
    /// Maximum cumulative decompressed bytes across all expansions.
    pub max_decompressed_bytes_total: u64,
    /// Maximum decompressed bytes produced by any single expansion.
    pub max_decompressed_bytes_per_expand: u64,
    /// Maximum bytes decoded output retains: native record text, arena
    /// buffers, and retained copies of source bytes.
    #[serde(default = "default_max_retained_bytes")]
    pub max_retained_bytes: u64,
    /// Maximum admitted semantic or native entities.
    #[serde(default = "default_max_entities")]
    pub max_entities: u64,
    /// Maximum active recursive nesting depth.
    #[serde(default = "default_max_recursion_depth")]
    pub max_recursion_depth: u64,
}

impl ResourceLimits {
    /// Generous ceilings for interactive desktop use; the default profile.
    pub const fn desktop() -> Self {
        Self {
            max_input_bytes: 4 * GIB,
            max_decompressed_bytes_total: 8 * GIB,
            max_decompressed_bytes_per_expand: 2 * GIB,
            max_retained_bytes: 4 * GIB,
            max_entities: 64_000_000,
            max_recursion_depth: 256,
        }
    }

    /// Tight ceilings for unattended service use.
    pub const fn service() -> Self {
        Self {
            max_input_bytes: 256 * MIB,
            max_decompressed_bytes_total: GIB,
            max_decompressed_bytes_per_expand: 256 * MIB,
            max_retained_bytes: 512 * MIB,
            max_entities: 8_000_000,
            max_recursion_depth: 128,
        }
    }
}

impl Default for ResourceLimits {
    fn default() -> Self {
        Self::desktop()
    }
}

/// Decode mode and resource ceilings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct DecodePolicy {
    /// How mandatory transfer losses are handled.
    pub mode: DecodeMode,
    /// The absolute ceilings.
    pub limits: ResourceLimits,
}

impl DecodePolicy {
    /// The default desktop profile in salvage mode.
    pub const fn desktop() -> Self {
        Self {
            mode: DecodeMode::Salvage,
            limits: ResourceLimits::desktop(),
        }
    }

    /// The tight service profile in salvage mode.
    pub const fn service() -> Self {
        Self {
            mode: DecodeMode::Salvage,
            limits: ResourceLimits::service(),
        }
    }
}

impl Default for DecodePolicy {
    fn default() -> Self {
        Self::desktop()
    }
}

/// Resource options for container inspection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct InspectOptions {
    /// The absolute ceilings applied during inspection.
    pub limits: ResourceLimits,
}

pub(super) const DECOMPRESSED_TOTAL_BASE: u64 = 16 * MIB;
pub(super) const DECOMPRESSED_TOTAL_PER_INPUT_BYTE: u64 = 1000;
pub(super) const DECOMPRESSED_PER_EXPAND_BASE: u64 = 16 * MIB;
pub(super) const DECOMPRESSED_PER_EXPAND_PER_INPUT_BYTE: u64 = 256;
pub(super) const RETAINED_BASE: u64 = 16 * MIB;
pub(super) const RETAINED_PER_INPUT_BYTE: u64 = 1000;

const fn default_max_retained_bytes() -> u64 {
    ResourceLimits::desktop().max_retained_bytes
}

const fn default_max_entities() -> u64 {
    ResourceLimits::desktop().max_entities
}

const fn default_max_recursion_depth() -> u64 {
    ResourceLimits::desktop().max_recursion_depth
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]

    use super::{DecodePolicy, ResourceLimits, GIB, MIB};

    #[test]
    fn desktop_ceilings_are_explicit() {
        let d = ResourceLimits::desktop();
        assert_eq!(d.max_input_bytes, 4 * GIB);
        assert_eq!(d.max_decompressed_bytes_total, 8 * GIB);
        assert_eq!(d.max_decompressed_bytes_per_expand, 2 * GIB);
        assert_eq!(d.max_retained_bytes, 4 * GIB);
        assert_eq!(d.max_entities, 64_000_000);
        assert_eq!(d.max_recursion_depth, 256);
    }

    #[test]
    fn service_ceilings_are_explicit() {
        let s = ResourceLimits::service();
        assert_eq!(s.max_input_bytes, 256 * MIB);
        assert_eq!(s.max_decompressed_bytes_total, GIB);
        assert_eq!(s.max_decompressed_bytes_per_expand, 256 * MIB);
        assert_eq!(s.max_retained_bytes, 512 * MIB);
        assert_eq!(s.max_entities, 8_000_000);
        assert_eq!(s.max_recursion_depth, 128);
    }

    #[test]
    fn added_limits_default_when_deserializing_old_policy() {
        let json = r#"{
            "max_input_bytes": 1,
            "max_decompressed_bytes_total": 2,
            "max_decompressed_bytes_per_expand": 3
        }"#;
        let limits: ResourceLimits = serde_json::from_str(json).unwrap();
        let desktop = ResourceLimits::desktop();
        assert_eq!(limits.max_retained_bytes, desktop.max_retained_bytes);
        assert_eq!(limits.max_entities, desktop.max_entities);
        assert_eq!(limits.max_recursion_depth, desktop.max_recursion_depth);
    }

    #[test]
    fn removed_limits_are_rejected_by_name() {
        for field in [
            "max_materialized_bytes",
            "max_collection_items",
            "max_work_units",
        ] {
            let limits = format!(
                r#"{{"max_input_bytes":1,"max_decompressed_bytes_total":2,"max_decompressed_bytes_per_expand":3,"{field}":4}}"#
            );
            let policy = format!(r#"{{"mode":"salvage","limits":{limits}}}"#);
            let expected = format!("unknown field `{field}`");
            let error = serde_json::from_str::<ResourceLimits>(&limits).unwrap_err();
            assert!(error.to_string().contains(&expected), "{error}");
            let error = serde_json::from_str::<DecodePolicy>(&policy).unwrap_err();
            assert!(error.to_string().contains(&expected), "{error}");
        }
    }
}