podman-lens 0.2.3

Version-aware Rust library for native Podman inspection and non-executing deployment planning
Documentation
//! Observed and target Podman versions backed by reviewed catalogue evidence.

use std::fmt;

use semver::Version;

use crate::{
    Diagnostic, DiagnosticCode, PodmanLensResult,
    evidence::{CapabilityCatalogueEntry, capability_catalogue},
};

fn parse_semantic_version(value: &str) -> PodmanLensResult<Version> {
    let normalized = Version::parse(value).map_err(|_| Diagnostic::new(DiagnosticCode::InvalidVersion))?;
    if !normalized.pre.is_empty() {
        return Err(Diagnostic::new(DiagnosticCode::InvalidVersion));
    }
    Ok(normalized)
}

fn parse_reported_version(value: &str) -> PodmanLensResult<Version> {
    let normalized = crate::evidence::normalized_reported_version(value)?;
    parse_semantic_version(normalized.as_deref().unwrap_or(value))
}

/// A validated Podman version reported by a Libpod service.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct ObservedPodmanVersion {
    original: String,
    normalized: Version,
}

impl ObservedPodmanVersion {
    /// Parses a complete semantic Podman version.
    ///
    /// # Errors
    ///
    /// Returns `PLN0004` when the version is malformed or a prerelease.
    pub fn parse(value: &str) -> PodmanLensResult<Self> {
        let normalized = parse_semantic_version(value)?;
        Ok(Self {
            original: value.to_owned(),
            normalized,
        })
    }

    pub(crate) fn parse_reported(value: &str) -> PodmanLensResult<Self> {
        Ok(Self {
            original: value.to_owned(),
            normalized: parse_reported_version(value)?,
        })
    }

    /// Returns the semantic version.
    #[must_use]
    pub const fn as_semver(&self) -> &Version {
        &self.normalized
    }

    /// Returns the exact spelling reported by the service.
    #[must_use]
    pub fn original(&self) -> &str {
        &self.original
    }
}

impl fmt::Display for ObservedPodmanVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.original.fmt(formatter)
    }
}

/// A validated Libpod API version reported or selected for a service.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct ObservedApiVersion {
    original: String,
    normalized: Version,
}

impl ObservedApiVersion {
    /// Parses a complete semantic Libpod API version.
    ///
    /// # Errors
    ///
    /// Returns `PLN0004` when the version is malformed or a prerelease.
    pub fn parse(value: &str) -> PodmanLensResult<Self> {
        let normalized = parse_semantic_version(value)?;
        Ok(Self {
            original: value.to_owned(),
            normalized,
        })
    }

    pub(crate) fn parse_reported(value: &str) -> PodmanLensResult<Self> {
        Ok(Self {
            original: value.to_owned(),
            normalized: parse_reported_version(value)?,
        })
    }

    /// Returns the semantic API version.
    #[must_use]
    pub const fn as_semver(&self) -> &Version {
        &self.normalized
    }

    /// Returns the exact spelling reported or selected by the caller.
    #[must_use]
    pub fn original(&self) -> &str {
        &self.original
    }
}

impl fmt::Display for ObservedApiVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.original.fmt(formatter)
    }
}

/// The reviewed, fail-closed Podman target range.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SupportedPodmanRange;

impl SupportedPodmanRange {
    /// The minimum reviewed Podman version.
    pub const MINIMUM: &'static str = "5.4.0";
    /// The exclusive upper bound of the reviewed Podman range.
    pub const MAXIMUM_EXCLUSIVE: &'static str = "6.2.0";

    /// Returns whether a version is inside the reviewed target range.
    #[must_use]
    pub fn contains(self, version: &ObservedPodmanVersion) -> bool {
        let version = version.as_semver();
        version >= &Version::new(5, 4, 0) && version < &Version::new(6, 2, 0)
    }
}

/// An explicit, evidence-backed target used to create Libpod operations.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TargetProfile {
    podman_version: ObservedPodmanVersion,
    api_version: ObservedApiVersion,
    execution_context: TargetExecutionContext,
    cgroup_capabilities: Option<CgroupCapabilityEvidence>,
}

/// Explicit privilege context of the selected deployment target.
///
/// Podman accepts some native settings, including static network addresses and MAC addresses,
/// only for rootful targets. The context is caller-supplied evidence; `PodmanLens` never probes the
/// development machine to infer it.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum TargetExecutionContext {
    /// The target privilege context has not been explicitly established.
    #[default]
    Unknown,
    /// The target is explicitly rootless.
    Rootless,
    /// The target is explicitly rootful.
    Rootful,
}

/// Caller-proven cgroup hierarchy version for the selected deployment target.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum CgroupVersion {
    /// The target uses the legacy cgroup v1 hierarchy.
    V1,
    /// The target uses the unified cgroup v2 hierarchy.
    V2,
}

/// One cgroup controller required by a bounded container resource control.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum CgroupController {
    /// CPU controller evidence.
    Cpu,
    /// Memory controller evidence.
    Memory,
    /// Process-ID controller evidence.
    Pids,
}

/// Explicit caller-supplied cgroup capability evidence.
///
/// `PodmanLens` never reads the local cgroup hierarchy and does not derive this from root mode.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CgroupCapabilityEvidence {
    version: CgroupVersion,
    controllers: std::collections::BTreeSet<CgroupController>,
}

impl CgroupCapabilityEvidence {
    /// Creates evidence for one cgroup hierarchy and controller set.
    #[must_use]
    pub fn new<I>(version: CgroupVersion, controllers: I) -> Self
    where
        I: IntoIterator<Item = CgroupController>,
    {
        Self {
            version,
            controllers: controllers.into_iter().collect(),
        }
    }

    /// Returns the caller-proven hierarchy version.
    #[must_use]
    pub const fn version(&self) -> CgroupVersion {
        self.version
    }

    /// Returns whether the selected controller is caller-proven available.
    #[must_use]
    pub fn supports(&self, controller: CgroupController) -> bool {
        self.controllers.contains(&controller)
    }
}

impl TargetProfile {
    /// Validates an explicit Podman and Libpod API target pair against the embedded catalogue.
    ///
    /// # Errors
    ///
    /// Returns `PLN0005` when the engine is outside reviewed evidence or the selected Libpod API
    /// is below the reviewed minimum or newer than that engine.
    pub fn new(podman_version: ObservedPodmanVersion, api_version: ObservedApiVersion) -> PodmanLensResult<Self> {
        if !SupportedPodmanRange.contains(&podman_version) {
            return Err(Diagnostic::new(DiagnosticCode::IncompatibleTargetProfile));
        }
        let entry = matching_catalogue_entry(&podman_version)?;
        if !api_version_is_supported(&entry, &podman_version, &api_version) {
            return Err(Diagnostic::new(DiagnosticCode::IncompatibleTargetProfile));
        }
        Ok(Self {
            podman_version,
            api_version,
            execution_context: TargetExecutionContext::Unknown,
            cgroup_capabilities: None,
        })
    }

    /// Records the caller-proven privilege context of the deployment target.
    pub fn set_execution_context(&mut self, context: TargetExecutionContext) {
        self.execution_context = context;
    }

    /// Records explicit caller-proven cgroup capabilities for resource planning.
    pub fn set_cgroup_capabilities(&mut self, capabilities: CgroupCapabilityEvidence) {
        self.cgroup_capabilities = Some(capabilities);
    }

    /// Returns the explicit Podman target version.
    #[must_use]
    pub fn podman_version(&self) -> &ObservedPodmanVersion {
        &self.podman_version
    }

    /// Returns the explicit Libpod API target version.
    #[must_use]
    pub fn api_version(&self) -> &ObservedApiVersion {
        &self.api_version
    }

    /// Returns the caller-proven privilege context of the deployment target.
    #[must_use]
    pub const fn execution_context(&self) -> TargetExecutionContext {
        self.execution_context
    }

    /// Returns caller-proven cgroup capabilities, if supplied.
    #[must_use]
    pub fn cgroup_capabilities(&self) -> Option<&CgroupCapabilityEvidence> {
        self.cgroup_capabilities.as_ref()
    }
}

fn matching_catalogue_entry(version: &ObservedPodmanVersion) -> PodmanLensResult<CapabilityCatalogueEntry> {
    capability_catalogue()?
        .into_iter()
        .find(|entry| entry.output_supported() && version_in_entry(version.as_semver(), entry))
        .ok_or_else(|| Diagnostic::new(DiagnosticCode::IncompatibleTargetProfile))
}

/// Returns immutable input evidence for one exact reviewed source runtime.
///
/// This is deliberately distinct from [`TargetProfile`]: input-only anchors can be observed and
/// migrated, but have no rendering evidence and cannot be selected as a deployment target.
pub(crate) fn matching_input_catalogue_entry(
    version: &ObservedPodmanVersion,
) -> PodmanLensResult<CapabilityCatalogueEntry> {
    capability_catalogue()?
        .into_iter()
        .find(|entry| {
            if entry.output_supported() {
                version_in_entry(version.as_semver(), entry)
            } else {
                entry.matches_reported_podman_version(version.original())
            }
        })
        .ok_or_else(|| Diagnostic::new(DiagnosticCode::ObservedCompatibility))
}

/// Checks that one observed engine/API pair is in reviewed input evidence.
pub(crate) fn observed_input_is_supported(
    podman_version: &ObservedPodmanVersion,
    api_version: &ObservedApiVersion,
) -> PodmanLensResult<CapabilityCatalogueEntry> {
    let entry = matching_input_catalogue_entry(podman_version)?;
    if observed_api_version_is_supported(&entry, podman_version, api_version) {
        Ok(entry)
    } else {
        Err(Diagnostic::new(DiagnosticCode::ObservedCompatibility))
    }
}

fn observed_api_version_is_supported(
    entry: &CapabilityCatalogueEntry,
    podman_version: &ObservedPodmanVersion,
    api_version: &ObservedApiVersion,
) -> bool {
    if !entry.output_supported() {
        return entry.matches_reported_version_pair(podman_version.original(), api_version.original());
    }
    api_version_is_supported(entry, podman_version, api_version)
}

fn version_in_entry(version: &Version, entry: &CapabilityCatalogueEntry) -> bool {
    let Ok(minimum) = Version::parse(entry.minimum_podman_version()) else {
        return false;
    };
    let Ok(maximum_exclusive) = Version::parse(entry.maximum_exclusive_podman_version()) else {
        return false;
    };
    version >= &minimum && version < &maximum_exclusive
}

fn api_version_is_supported(
    entry: &CapabilityCatalogueEntry,
    podman_version: &ObservedPodmanVersion,
    api_version: &ObservedApiVersion,
) -> bool {
    let Ok(minimum_api) = Version::parse(entry.minimum_libpod_api_version()) else {
        return false;
    };
    semantic_core_inclusive(api_version.as_semver(), &minimum_api, podman_version.as_semver())
}

fn semantic_core_inclusive(version: &Version, minimum: &Version, maximum: &Version) -> bool {
    version >= minimum && version <= maximum
}

#[cfg(test)]
mod tests {
    use super::{ObservedApiVersion, ObservedPodmanVersion, TargetProfile, observed_input_is_supported};

    #[test]
    fn legacy_distro_anchors_are_input_only_and_not_implicit_targets() -> Result<(), Box<dyn std::error::Error>> {
        for (engine, api) in [
            ("3.0.1", "3.0.0"),
            ("3.4.4", "3.4.4"),
            ("4.3.1", "4.3.1"),
            ("4.9.3", "4.9.3"),
            ("4.9.4", "4.9.4"),
        ] {
            let engine = ObservedPodmanVersion::parse(engine)?;
            let api = ObservedApiVersion::parse(api)?;
            let evidence = observed_input_is_supported(&engine, &api)?;
            assert!(!evidence.output_supported());
            assert!(TargetProfile::new(engine, api).is_err());
        }
        Ok(())
    }

    #[test]
    fn unreviewed_legacy_patches_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
        let engine = ObservedPodmanVersion::parse("3.4.5")?;
        let api = ObservedApiVersion::parse("3.4.5")?;
        assert!(observed_input_is_supported(&engine, &api).is_err());
        Ok(())
    }

    #[test]
    fn legacy_anchors_require_the_exact_observed_api_version() -> Result<(), Box<dyn std::error::Error>> {
        for (engine, wrong_api) in [("3.0.1", "3.0.1"), ("3.4.4", "3.1.0"), ("4.9.4", "4.0.0")] {
            let engine = ObservedPodmanVersion::parse(engine)?;
            let wrong_api = ObservedApiVersion::parse(wrong_api)?;
            assert!(observed_input_is_supported(&engine, &wrong_api).is_err());
        }
        Ok(())
    }
}