use std::{collections::BTreeMap, fmt, net::IpAddr};
use serde_json::{Map, Value};
use crate::observation::{
AuthoredImageSpellingHint, AuthoredMountRelabelHint, ConfiguredContainerCommand, ConfiguredContainerEntrypoint,
ConfiguredContainerHostname, ConfiguredContainerUser, ConfiguredContainerWorkdir, ContainerCreationEvidence,
ContainerMountKind, ContainerMountObservation, ContainerMountSelinuxRelabel, ContainerMountSource,
ContainerObservation, ContainerSecretGrantObservation, ContainerSecretReference, ImageObservation,
ImageObservationFields, Labels, NativeCapability, NativeHealthCheckObservation, NativeHealthCommand,
NativeHealthFailureAction, NativeIpcNamespaceMode, NativeLogDriver, NativeLoggingObservation, NativeNamespaceMode,
NativeNamespaceObservation, NativeNetworkCidr, NativeNetworkLeaseRange, NativeNetworkRouteObservation,
NativeNetworkRouteType, NativeNetworkSubnetObservation, NativeNetworkingObservation, NativeOpaqueNetworkOptions,
NativeOpaqueSecurityOptions, NativePortBindingObservation, NativePortProtocol, NativeRelationship,
NativeResourceControlObservation, NativeResourceReference, NativeRestartPolicyName, NativeRestartPolicyObservation,
NativeSecretDriverObservation, NativeSecretDriverOptions, NativeSecurityObservation,
NativeStartupHealthCheckObservation, NativeTimestamp, NativeUlimitObservation, NetworkObservation,
NetworkOptionKeys, ObservationField, ObservationHeader, ObservationOrigin, ObservedValue, PodObservation,
ProtectedEnvironment, ProtectedEnvironmentEntry, ProtectedEnvironmentValue, ProtectedHealthCommand,
ResourceDetails, ResourceObservation, ResourceObservationState, SecretObservation, UnixId as VolumeOwnerUnixId,
UnmodelledCompleteness, UnmodelledField, VolumeObservation, VolumeOwnerIdWireValue,
};
use crate::{
CapabilityCatalogueEntry, Diagnostic, DiagnosticCode, LibpodMethod, LibpodPath, LibpodRequest, LibpodResponse,
LibpodTransport, ObservedApiVersion, PodmanLensResult, ServiceObservation, probe_libpod_service,
};
pub const MAX_INVENTORY_JSON_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_UNKNOWN_FIELDS_PER_RECORD: usize = 128;
pub const MAX_UNKNOWN_FIELDS_PER_INVENTORY: usize = 2_048;
const CONTAINER_RUNTIME_ONLY_TOP_LEVEL_FIELDS: &[&str] = &[
"AppArmorProfile",
"Args",
"BoundingCaps",
"ConmonPidFile",
"Created",
"Driver",
"EffectiveCaps",
"ExecIDs",
"ExitCommand",
"GraphDriver",
"HostnamePath",
"HostsPath",
"LockNumber",
"MountLabel",
"Namespace",
"OCIConfigPath",
"OCIRuntime",
"Path",
"PidFile",
"ProcessLabel",
"ResolvConfPath",
"RestartCount",
"Rootfs",
"SizeRootFs",
"SizeRw",
"State",
"StaticDir",
];
const IMAGE_RUNTIME_ONLY_TOP_LEVEL_FIELDS: &[&str] = &[
"GraphDriver",
"History",
"NamesHistory",
"Parent",
"RootFS",
"Size",
"VirtualSize",
"Version",
];
const VOLUME_RUNTIME_ONLY_TOP_LEVEL_FIELDS: &[&str] = &[
"LockNumber",
"MountCount",
"Mountpoint",
"NeedsChown",
"NeedsCopyUp",
"Scope",
];
const NETWORK_RUNTIME_ONLY_TOP_LEVEL_FIELDS: &[&str] = &["containers", "created"];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum EnvironmentValuePolicy {
#[default]
Redact,
Include,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AcquisitionOptions {
environment_values: EnvironmentValuePolicy,
}
impl AcquisitionOptions {
#[must_use]
pub const fn redacted() -> Self {
Self {
environment_values: EnvironmentValuePolicy::Redact,
}
}
#[must_use]
pub const fn include_environment_values() -> Self {
Self {
environment_values: EnvironmentValuePolicy::Include,
}
}
#[must_use]
pub const fn environment_value_policy(self) -> EnvironmentValuePolicy {
self.environment_values
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ResourceKind {
Container,
Pod,
Network,
Volume,
Image,
Secret,
}
impl ResourceKind {
const ALL: [Self; 6] = [
Self::Container,
Self::Pod,
Self::Network,
Self::Volume,
Self::Image,
Self::Secret,
];
#[must_use]
pub const fn canonical_rank(self) -> u8 {
match self {
Self::Container => 0,
Self::Pod => 1,
Self::Network => 2,
Self::Volume => 3,
Self::Image => 4,
Self::Secret => 5,
}
}
const fn collection(self) -> &'static str {
match self {
Self::Container => "containers",
Self::Pod => "pods",
Self::Network => "networks",
Self::Volume => "volumes",
Self::Image => "images",
Self::Secret => "secrets",
}
}
}
impl Ord for ResourceKind {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.canonical_rank().cmp(&other.canonical_rank())
}
}
impl PartialOrd for ResourceKind {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct ResourceIdentity {
kind: ResourceKind,
id: String,
name: Option<String>,
}
impl ResourceIdentity {
fn new(kind: ResourceKind, id: String, name: Option<String>) -> Self {
Self { kind, id, name }
}
#[must_use]
pub const fn kind(&self) -> ResourceKind {
self.kind
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResourceEvidence {
engine_version: String,
api_version: String,
capability: CapabilityCatalogueEntry,
}
impl ResourceEvidence {
fn from_service(service: &ServiceObservation) -> Self {
Self {
engine_version: service.engine_version().original().to_owned(),
api_version: service.api_version().original().to_owned(),
capability: service.input_capability().clone(),
}
}
#[must_use]
pub fn engine_version(&self) -> &str {
&self.engine_version
}
#[must_use]
pub fn api_version(&self) -> &str {
&self.api_version
}
#[must_use]
pub fn capability(&self) -> &CapabilityCatalogueEntry {
&self.capability
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum JsonValueKind {
Null,
Boolean,
Number,
String,
Array,
Object,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InventoryFinding {
code: DiagnosticCode,
resource: Option<ResourceIdentity>,
field_path: Option<String>,
occurrence: Option<usize>,
}
impl InventoryFinding {
fn section(code: DiagnosticCode) -> Self {
Self {
code,
resource: None,
field_path: None,
occurrence: None,
}
}
fn for_resource(code: DiagnosticCode, resource: ResourceIdentity) -> Self {
Self {
code,
resource: Some(resource),
field_path: None,
occurrence: None,
}
}
fn field(code: DiagnosticCode, resource: ResourceIdentity, field_path: impl Into<String>) -> Self {
Self {
code,
resource: Some(resource),
field_path: Some(field_path.into()),
occurrence: None,
}
}
fn at_occurrence(
code: DiagnosticCode,
resource: ResourceIdentity,
field_path: impl Into<String>,
occurrence: usize,
) -> Self {
Self {
code,
resource: Some(resource),
field_path: Some(field_path.into()),
occurrence: Some(occurrence),
}
}
#[must_use]
pub const fn code(&self) -> DiagnosticCode {
self.code
}
#[must_use]
pub fn resource(&self) -> Option<&ResourceIdentity> {
self.resource.as_ref()
}
#[must_use]
pub fn field_path(&self) -> Option<&str> {
self.field_path.as_deref()
}
#[must_use]
pub const fn occurrence(&self) -> Option<usize> {
self.occurrence
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct SensitiveEnvironmentValue(String);
impl SensitiveEnvironmentValue {
fn new(value: String) -> Self {
Self(value)
}
pub fn expose<R>(&self, use_value: impl FnOnce(&str) -> R) -> R {
use_value(&self.0)
}
}
impl fmt::Debug for SensitiveEnvironmentValue {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SensitiveEnvironmentValue([redacted])")
}
}
impl fmt::Display for SensitiveEnvironmentValue {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("[redacted]")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum InventorySectionAvailability {
Available,
Unavailable,
Malformed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InventorySection {
kind: ResourceKind,
availability: InventorySectionAvailability,
observations: Vec<ResourceObservation>,
findings: Vec<InventoryFinding>,
}
impl InventorySection {
fn unavailable(kind: ResourceKind, code: DiagnosticCode) -> Self {
Self {
kind,
availability: if code == DiagnosticCode::InventoryJson || code == DiagnosticCode::InventoryShape {
InventorySectionAvailability::Malformed
} else {
InventorySectionAvailability::Unavailable
},
observations: Vec::new(),
findings: vec![InventoryFinding::section(code)],
}
}
fn version_inapplicable(kind: ResourceKind) -> Self {
Self {
kind,
availability: InventorySectionAvailability::Unavailable,
observations: Vec::new(),
findings: vec![InventoryFinding::section(DiagnosticCode::VersionInapplicableField)],
}
}
#[must_use]
pub const fn kind(&self) -> ResourceKind {
self.kind
}
#[must_use]
pub const fn availability(&self) -> InventorySectionAvailability {
self.availability
}
#[must_use]
pub fn observations(&self) -> &[ResourceObservation] {
&self.observations
}
#[must_use]
pub fn findings(&self) -> &[InventoryFinding] {
&self.findings
}
pub(crate) fn is_available(&self) -> bool {
self.availability == InventorySectionAvailability::Available
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResourceInventory {
service: ServiceObservation,
sections: Vec<InventorySection>,
}
impl ResourceInventory {
#[must_use]
pub fn service(&self) -> &ServiceObservation {
&self.service
}
#[must_use]
pub fn sections(&self) -> &[InventorySection] {
&self.sections
}
#[must_use]
pub fn section(&self, kind: ResourceKind) -> Option<&InventorySection> {
self.sections.iter().find(|section| section.kind == kind)
}
pub fn observations(&self) -> impl Iterator<Item = &ResourceObservation> {
self.sections.iter().flat_map(InventorySection::observations)
}
#[must_use]
pub fn observation(&self, identity: &ResourceIdentity) -> Option<&ResourceObservation> {
self.observations()
.find(|observation| observation.header().identity() == identity)
}
}
pub async fn acquire_inventory(
transport: &dyn LibpodTransport,
options: AcquisitionOptions,
) -> PodmanLensResult<ResourceInventory> {
let service = probe_libpod_service(transport).await?;
let evidence = ResourceEvidence::from_service(&service);
let mut listed = Vec::with_capacity(ResourceKind::ALL.len());
for kind in ResourceKind::ALL {
if resource_kind_is_version_inapplicable(kind, &service) {
listed.push((kind, Err(Diagnostic::new(DiagnosticCode::VersionInapplicableField))));
} else {
listed.push((kind, list_section(transport, &service, kind).await));
}
}
let later_kind_reservations = later_kind_unknown_field_reservations(&listed);
let mut sections = Vec::with_capacity(ResourceKind::ALL.len());
let mut remaining_unknown_fields = MAX_UNKNOWN_FIELDS_PER_INVENTORY;
for (section_index, (kind, identities)) in listed.into_iter().enumerate() {
match identities {
Ok(listed) => {
let mut observations = Vec::with_capacity(listed.identities.len());
for identity in listed.identities {
let observation = inspect_observation(
transport,
service.api_version(),
evidence.clone(),
identity,
options,
remaining_unknown_fields
.saturating_sub(later_kind_reservations[section_index])
.min(MAX_UNKNOWN_FIELDS_PER_RECORD),
)
.await;
remaining_unknown_fields =
remaining_unknown_fields.saturating_sub(observation.header().unmodelled_fields().len());
observations.push(observation);
}
sections.push(InventorySection {
kind,
availability: InventorySectionAvailability::Available,
observations,
findings: listed.findings,
});
}
Err(error) if error.code() == DiagnosticCode::VersionInapplicableField => {
sections.push(InventorySection::version_inapplicable(kind));
}
Err(error) => sections.push(InventorySection::unavailable(kind, error.code())),
}
}
reconcile_relationships(&mut sections);
Ok(ResourceInventory { service, sections })
}
fn later_kind_unknown_field_reservations(listed: &[(ResourceKind, PodmanLensResult<ListedSection>)]) -> Vec<usize> {
(0..listed.len())
.map(|section_index| {
listed[section_index + 1..]
.iter()
.filter(|(_, section)| section.as_ref().is_ok_and(|section| !section.identities.is_empty()))
.count()
})
.collect()
}
fn resource_kind_is_version_inapplicable(kind: ResourceKind, service: &ServiceObservation) -> bool {
kind == ResourceKind::Secret && service.engine_version().as_semver() < &semver::Version::new(3, 1, 0)
}
#[allow(clippy::too_many_lines)] fn reconcile_relationships(sections: &mut [InventorySection]) {
let available = sections
.iter()
.map(|section| (section.kind, section.is_available()))
.collect::<BTreeMap<_, _>>();
let mut targets = BTreeMap::<(ResourceKind, String), Vec<String>>::new();
for section in sections.iter() {
for observation in §ion.observations {
let identity = observation.header().identity();
targets
.entry((identity.kind(), identity.id().to_owned()))
.or_default()
.push(identity.id().to_owned());
if let Some(name) = identity.name() {
targets
.entry((identity.kind(), name.to_owned()))
.or_default()
.push(identity.id().to_owned());
}
for aliases in [observation.image_repo_tags(), observation.image_repo_digests()] {
if let Some(ObservationField::Observed(aliases)) = aliases {
for alias in aliases.value() {
targets
.entry((identity.kind(), alias.clone()))
.or_default()
.push(identity.id().to_owned());
}
}
}
}
}
for candidates in targets.values_mut() {
candidates.sort();
candidates.dedup();
}
for section in sections.iter_mut() {
for observation in &mut section.observations {
let Some(ObservationField::Observed(relationships)) = observation.relationships() else {
continue;
};
let relationship_findings = relationships
.value()
.iter()
.filter(|relationship| available.get(&relationship.kind).copied().unwrap_or(false))
.flat_map(
|relationship| match resolve_relationship_target(&targets, relationship) {
RelationshipTarget::One(_) => Vec::new(),
RelationshipTarget::Unresolved => relationship
.field_paths
.iter()
.cloned()
.map(|path| (DiagnosticCode::UnresolvedRelationship, path))
.collect(),
RelationshipTarget::Ambiguous => relationship
.field_paths
.iter()
.cloned()
.map(|path| (DiagnosticCode::RelationshipAmbiguous, path))
.collect(),
RelationshipTarget::Conflict => relationship
.field_paths
.iter()
.cloned()
.map(|path| (DiagnosticCode::RelationshipConflict, path))
.collect(),
},
)
.collect::<Vec<_>>();
let pod_membership_unresolved = observation.header().identity().kind() == ResourceKind::Pod
&& relationships.value().iter().any(|relationship| {
relationship.kind == ResourceKind::Container
&& !matches!(
resolve_relationship_target(&targets, relationship),
RelationshipTarget::One(_)
)
});
let identity = observation.header().identity().clone();
observation.header_mut().findings_mut().extend(
relationship_findings
.into_iter()
.map(|(code, path)| InventoryFinding::field(code, identity.clone(), path)),
);
if pod_membership_unresolved {
observation.header_mut().findings_mut().push(InventoryFinding::field(
DiagnosticCode::PodMembershipConflict,
identity,
"$.Containers",
));
}
}
}
let mut pod_members = BTreeMap::<String, Vec<String>>::new();
let mut container_pods = BTreeMap::<String, Vec<String>>::new();
for section in sections.iter() {
if !section.is_available() {
continue;
}
for observation in §ion.observations {
let identity = observation.header().identity();
let Some(ObservationField::Observed(relationships)) = observation.relationships() else {
continue;
};
for relationship in relationships.value() {
match (identity.kind(), relationship.kind) {
(ResourceKind::Pod, ResourceKind::Container) => {
if let RelationshipTarget::One(target) = resolve_relationship_target(&targets, relationship) {
pod_members
.entry(identity.id().to_owned())
.or_default()
.push(target.to_owned());
}
}
(ResourceKind::Container, ResourceKind::Pod) => {
if let RelationshipTarget::One(target) = resolve_relationship_target(&targets, relationship) {
container_pods
.entry(identity.id().to_owned())
.or_default()
.push(target.to_owned());
}
}
_ => {}
}
}
}
}
for section in sections.iter_mut() {
for observation in &mut section.observations {
let identity = observation.header().identity().clone();
let conflict = match identity.kind() {
ResourceKind::Pod => pod_members.get(identity.id()).is_some_and(|members| {
members.iter().any(|member| {
!container_pods
.get(member)
.is_some_and(|pods| pods.contains(&identity.id().to_owned()))
})
}),
ResourceKind::Container => container_pods.get(identity.id()).is_some_and(|pods| {
pods.iter().any(|pod| {
!pod_members
.get(pod)
.is_some_and(|members| members.contains(&identity.id().to_owned()))
})
}),
_ => false,
};
if conflict {
observation.header_mut().findings_mut().push(InventoryFinding::field(
DiagnosticCode::PodMembershipConflict,
identity,
"$.PodMembership",
));
}
}
}
for section in sections.iter_mut() {
for observation in &mut section.observations {
let ResourceDetails::Container(container) = observation.details() else {
continue;
};
let (ObservationField::Observed(configured), ObservationField::Observed(local)) =
(container.configured_image(), container.local_image_id())
else {
continue;
};
let configured = NativeRelationship::new(ResourceKind::Image, configured.value(), "$.ImageName");
let local = NativeRelationship::new(ResourceKind::Image, local.value(), "$.Image");
if let (RelationshipTarget::One(configured), RelationshipTarget::One(local)) = (
resolve_relationship_target(&targets, &configured),
resolve_relationship_target(&targets, &local),
) {
if configured != local {
let identity = observation.header().identity().clone();
observation.header_mut().findings_mut().push(InventoryFinding::field(
DiagnosticCode::RelationshipConflict,
identity,
"$.ImageName",
));
}
}
}
}
}
enum RelationshipTarget<'a> {
One(&'a str),
Unresolved,
Ambiguous,
Conflict,
}
fn resolve_relationship_target<'a>(
targets: &'a BTreeMap<(ResourceKind, String), Vec<String>>,
relationship: &NativeRelationship,
) -> RelationshipTarget<'a> {
let mut resolved = std::collections::BTreeSet::new();
for reference in &relationship.references {
let Some(candidates) = targets.get(&(relationship.kind, reference.clone())) else {
return RelationshipTarget::Unresolved;
};
let [candidate] = candidates.as_slice() else {
return RelationshipTarget::Ambiguous;
};
resolved.insert(candidate.as_str());
}
match resolved.into_iter().collect::<Vec<_>>().as_slice() {
[target] => RelationshipTarget::One(target),
[] => RelationshipTarget::Unresolved,
_ => RelationshipTarget::Conflict,
}
}
struct ListedSection {
identities: Vec<ResourceIdentity>,
findings: Vec<InventoryFinding>,
}
async fn list_section(
transport: &dyn LibpodTransport,
service: &ServiceObservation,
kind: ResourceKind,
) -> PodmanLensResult<ListedSection> {
let list_path = list_path(service.api_version(), kind);
let path = list_path?;
let response = send_get(transport, path).await;
response.and_then(|response| decode_list(kind, &response))
}
async fn inspect_observation(
transport: &dyn LibpodTransport,
api_version: &ObservedApiVersion,
evidence: ResourceEvidence,
identity: ResourceIdentity,
options: AcquisitionOptions,
unknown_field_limit: usize,
) -> ResourceObservation {
let Ok(path) = LibpodPath::resource(api_version, identity.kind.collection(), identity.id(), "json") else {
return partial_observation(identity, evidence, DiagnosticCode::ResourceMalformed);
};
let response = send_get(transport, path).await;
match response {
Ok(response) if response.status() == 404 => {
partial_observation(identity, evidence, DiagnosticCode::ResourceUnavailable)
}
Ok(response) => {
match decode_observation(&identity, &response, evidence.clone(), options, unknown_field_limit) {
Ok(record) => record,
Err(error) => partial_observation(identity, evidence, error.code()),
}
}
Err(_) => partial_observation(identity, evidence, DiagnosticCode::ResourceUnavailable),
}
}
async fn send_get(transport: &dyn LibpodTransport, path: LibpodPath) -> PodmanLensResult<LibpodResponse> {
let request = LibpodRequest::new(LibpodMethod::Get, path, Vec::new())?;
transport
.send(&request)
.await
.map_err(|error| error.diagnostic().clone())
}
fn list_path(api_version: &ObservedApiVersion, kind: ResourceKind) -> PodmanLensResult<LibpodPath> {
let query = match kind {
ResourceKind::Container => "?all=true&sync=true",
ResourceKind::Image => "?all=true",
_ => "",
};
LibpodPath::parse(format!(
"/v{}/libpod/{}/json{query}",
api_version.as_semver(),
kind.collection()
))
}
fn decode_list(kind: ResourceKind, response: &LibpodResponse) -> PodmanLensResult<ListedSection> {
require_ok_json(response)?;
let value = decode_json(response.body())?;
let entries = match kind {
ResourceKind::Volume => match &value {
Value::Array(entries) => entries.iter().collect(),
Value::Object(object) => match object.get("Volumes") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(entries)) => entries.iter().collect(),
Some(_) => return Err(Diagnostic::new(DiagnosticCode::InventoryShape)),
},
_ => return Err(Diagnostic::new(DiagnosticCode::InventoryShape)),
},
_ => value
.as_array()
.map(|entries| entries.iter().collect())
.ok_or_else(|| Diagnostic::new(DiagnosticCode::InventoryShape))?,
};
let mut identities = Vec::with_capacity(entries.len());
let mut findings = Vec::new();
for entry in entries {
match list_identity(kind, entry) {
Ok(identity)
if identities
.iter()
.any(|previous: &ResourceIdentity| previous.id == identity.id) =>
{
findings.push(InventoryFinding::for_resource(
DiagnosticCode::ResourceMalformed,
identity,
));
}
Ok(identity) => identities.push(identity),
Err(_) => findings.push(InventoryFinding::section(DiagnosticCode::ResourceMalformed)),
}
}
identities.sort_by(|left, right| left.id.cmp(&right.id).then_with(|| left.name.cmp(&right.name)));
Ok(ListedSection { identities, findings })
}
fn list_identity(kind: ResourceKind, value: &Value) -> PodmanLensResult<ResourceIdentity> {
let object = value
.as_object()
.ok_or_else(|| Diagnostic::new(DiagnosticCode::InventoryShape))?;
let id = match kind {
ResourceKind::Volume => required_string(object, "Name")?,
ResourceKind::Secret => required_string(object, "ID")?,
ResourceKind::Network => required_string_any(object, &["id", "Name", "name"])?,
_ => required_string(object, "Id")?,
};
let name = match kind {
ResourceKind::Container | ResourceKind::Image => first_string(object.get("Names")),
ResourceKind::Secret => object
.get("Spec")
.and_then(Value::as_object)
.and_then(|spec| optional_string(spec, "Name")),
ResourceKind::Network => optional_string_any(object, &["name", "Name"]),
_ => optional_string(object, "Name"),
};
Ok(ResourceIdentity::new(kind, id.to_owned(), name.map(ToOwned::to_owned)))
}
fn decode_observation(
listed_identity: &ResourceIdentity,
response: &LibpodResponse,
evidence: ResourceEvidence,
options: AcquisitionOptions,
unknown_field_limit: usize,
) -> PodmanLensResult<ResourceObservation> {
require_ok_json(response)?;
let value = decode_json(response.body())?;
let legacy_cni_network = listed_identity.kind == ResourceKind::Network
&& semver::Version::parse(evidence.engine_version()).is_ok_and(|version| version.major == 3);
let normalized_network = legacy_cni_network
.then(|| normalize_legacy_cni_network(&value))
.transpose()?;
let value = normalized_network.as_ref().unwrap_or(&value);
let object = value
.as_object()
.ok_or_else(|| Diagnostic::new(DiagnosticCode::InventoryShape))?;
let (
identity,
labels,
relationships,
environment,
command,
entrypoint,
user,
working_directory,
hostname,
pod_membership,
native_dependencies,
mounts,
secret_grants,
image_repo_tags,
network,
memory_swappiness,
configured_image,
local_image_id,
is_infra,
container_networking,
pod_create_infra,
pod_networking,
secret_driver,
volume_owner_user,
volume_owner_group,
container_b3,
b4,
mut findings,
known,
) = match listed_identity.kind {
ResourceKind::Container => decode_container(listed_identity, object, options, &evidence)?,
ResourceKind::Pod => decode_pod(listed_identity, object, &evidence)?,
ResourceKind::Network => decode_network(listed_identity, object, &evidence)?,
ResourceKind::Volume => decode_volume(listed_identity, object)?,
ResourceKind::Image => decode_image(listed_identity, object, options)?,
ResourceKind::Secret => decode_secret(listed_identity, object)?,
};
let mut unknown_fields = UnknownFieldCollector::new(&identity, &evidence, unknown_field_limit);
unknown_top_level(listed_identity.kind, object, known, &mut unknown_fields);
unknown_nested_fields(listed_identity.kind, object, &evidence, &mut unknown_fields);
append_container_unmodelled(&mut unknown_fields, container_b3.as_ref());
let (unknown_fields, unmodelled_completeness) = finish_unknown_fields(unknown_fields, &identity, &mut findings);
let header = ObservationHeader::complete(
identity.clone(),
evidence,
findings,
unknown_fields,
unmodelled_completeness,
);
ResourceObservation::try_new(
header,
details_from_decoded(
identity.kind(),
DecodedDetails {
labels,
relationships,
environment,
command,
entrypoint,
user,
working_directory,
hostname,
pod_membership,
native_dependencies,
mounts,
secret_grants,
image_repo_tags,
network,
memory_swappiness,
configured_image,
container_b3,
local_image_id,
is_infra,
container_networking,
pod_create_infra,
pod_networking,
secret_driver,
volume_owner_user,
volume_owner_group,
b4,
},
),
)
}
fn normalize_legacy_cni_network(value: &Value) -> PodmanLensResult<Value> {
let configuration = if let Some(configuration) = value.as_object() {
if configuration.contains_key("cniVersion") {
configuration
} else {
return Ok(value.clone());
}
} else {
let Some(configurations) = value.as_array() else {
return Ok(value.clone());
};
let [configuration] = configurations.as_slice() else {
return Err(Diagnostic::new(DiagnosticCode::InventoryShape));
};
configuration
.as_object()
.ok_or_else(|| Diagnostic::new(DiagnosticCode::InventoryShape))?
};
let name = required_string(configuration, "name")?;
let mut normalized = Map::new();
normalized.insert("id".to_owned(), Value::String(name.to_owned()));
normalized.insert("name".to_owned(), Value::String(name.to_owned()));
normalized.insert("CniConfig".to_owned(), Value::Object(configuration.clone()));
let mut subnets = Vec::new();
let mut routes = Vec::new();
for plugin in configuration
.get("plugins")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(plugin) = plugin.as_object() else {
continue;
};
if plugin.get("type").and_then(Value::as_str) != Some("bridge") {
continue;
}
let Some(ipam) = plugin.get("ipam").and_then(Value::as_object) else {
continue;
};
for range in ipam.get("ranges").and_then(Value::as_array).into_iter().flatten() {
for subnet in range.as_array().into_iter().flatten() {
let Some(subnet) = subnet.as_object() else {
continue;
};
let Some(cidr) = subnet.get("subnet").and_then(Value::as_str) else {
continue;
};
let mut mapped = Map::new();
mapped.insert("subnet".to_owned(), Value::String(cidr.to_owned()));
if let Some(gateway) = subnet.get("gateway").and_then(Value::as_str) {
mapped.insert("gateway".to_owned(), Value::String(gateway.to_owned()));
}
subnets.push(Value::Object(mapped));
}
}
for route in ipam.get("routes").and_then(Value::as_array).into_iter().flatten() {
let Some(route) = route.as_object() else {
continue;
};
let (Some(destination), Some(gateway)) = (
route.get("dst").and_then(Value::as_str),
route.get("gw").and_then(Value::as_str),
) else {
continue;
};
routes.push(Value::Object(Map::from_iter([
("destination".to_owned(), Value::String(destination.to_owned())),
("gateway".to_owned(), Value::String(gateway.to_owned())),
])));
}
}
if !subnets.is_empty() {
normalized.insert("subnets".to_owned(), Value::Array(subnets));
}
if !routes.is_empty() {
normalized.insert("routes".to_owned(), Value::Array(routes));
}
Ok(Value::Object(normalized))
}
fn append_container_unmodelled(
unknown_fields: &mut UnknownFieldCollector<'_>,
container_b3: Option<&ContainerB3Decoded>,
) {
if let Some(container_b3) = container_b3 {
for (path, kind) in &container_b3.unmodelled {
if !unknown_fields.push_kind(path.clone(), *kind) {
break;
}
}
}
}
fn finish_unknown_fields(
unknown_fields: UnknownFieldCollector<'_>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> (Vec<UnmodelledField>, UnmodelledCompleteness) {
let (unknown_fields, overflowed) = unknown_fields.finish();
if overflowed {
findings.push(InventoryFinding::field(
DiagnosticCode::UnknownFieldOverflow,
identity.clone(),
"$",
));
}
findings.extend(
unknown_fields.iter().map(|field| {
InventoryFinding::field(DiagnosticCode::NativeFieldUnsupported, identity.clone(), field.path())
}),
);
let completeness = if overflowed {
UnmodelledCompleteness::Incomplete
} else {
UnmodelledCompleteness::Complete
};
(unknown_fields, completeness)
}
type Decoded = (
ResourceIdentity,
ObservationField<Labels>,
ObservationField<Vec<NativeRelationship>>,
ObservationField<ProtectedEnvironment>,
Option<ObservationField<ConfiguredContainerCommand>>,
Option<ObservationField<ConfiguredContainerEntrypoint>>,
Option<ObservationField<ConfiguredContainerUser>>,
Option<ObservationField<ConfiguredContainerWorkdir>>,
Option<ObservationField<ConfiguredContainerHostname>>,
Option<ObservationField<NativeResourceReference>>,
Option<ObservationField<Vec<NativeResourceReference>>>,
Option<ObservationField<Vec<ContainerMountObservation>>>,
Option<ObservationField<Vec<ContainerSecretGrantObservation>>>,
ObservationField<Vec<String>>,
Option<NetworkDecoded>,
Option<ObservationField<u64>>,
Option<ObservationField<String>>,
Option<ObservationField<String>>,
Option<ObservationField<bool>>,
Option<ObservationField<NativeNetworkingObservation>>,
Option<ObservationField<bool>>,
Option<ObservationField<NativeNetworkingObservation>>,
Option<ObservationField<NativeSecretDriverObservation>>,
Option<ObservationField<VolumeOwnerIdWireValue>>,
Option<ObservationField<VolumeOwnerIdWireValue>>,
Option<ContainerB3Decoded>,
Option<B4Decoded>,
Vec<InventoryFinding>,
&'static [&'static str],
);
type NetworkDecoded = (
ObservationField<bool>,
ObservationField<NetworkOptionKeys>,
ObservationField<Vec<NativeNetworkSubnetObservation>>,
ObservationField<Vec<NativeNetworkRouteObservation>>,
);
struct ContainerB3Decoded {
creation_evidence: ObservationField<ContainerCreationEvidence>,
restart_policy: ObservationField<NativeRestartPolicyObservation>,
health_check: ObservationField<NativeHealthCheckObservation>,
health_failure_action: ObservationField<NativeHealthFailureAction>,
startup_health_check: ObservationField<NativeStartupHealthCheckObservation>,
logging: ObservationField<NativeLoggingObservation>,
security: ObservationField<NativeSecurityObservation>,
namespaces: ObservationField<NativeNamespaceObservation>,
resource_controls: ObservationField<NativeResourceControlObservation>,
unmodelled: Vec<(String, JsonValueKind)>,
}
enum B4Decoded {
Volume {
driver: ObservationField<String>,
created_at: ObservationField<NativeTimestamp>,
anonymous: ObservationField<bool>,
},
Image {
repo_digests: ObservationField<Vec<String>>,
digest: ObservationField<String>,
created: ObservationField<NativeTimestamp>,
author: ObservationField<String>,
architecture: ObservationField<String>,
operating_system: ObservationField<String>,
manifest_type: ObservationField<String>,
},
Secret {
created_at: ObservationField<NativeTimestamp>,
updated_at: ObservationField<NativeTimestamp>,
},
}
impl Default for ContainerB3Decoded {
fn default() -> Self {
Self {
creation_evidence: ObservationField::NotApplicable,
restart_policy: ObservationField::NotApplicable,
health_check: ObservationField::NotApplicable,
health_failure_action: ObservationField::NotApplicable,
startup_health_check: ObservationField::NotApplicable,
logging: ObservationField::NotApplicable,
security: ObservationField::NotApplicable,
namespaces: ObservationField::NotApplicable,
resource_controls: ObservationField::NotApplicable,
unmodelled: Vec::new(),
}
}
}
struct DecodedDetails {
labels: ObservationField<Labels>,
relationships: ObservationField<Vec<NativeRelationship>>,
environment: ObservationField<ProtectedEnvironment>,
command: Option<ObservationField<ConfiguredContainerCommand>>,
entrypoint: Option<ObservationField<ConfiguredContainerEntrypoint>>,
user: Option<ObservationField<ConfiguredContainerUser>>,
working_directory: Option<ObservationField<ConfiguredContainerWorkdir>>,
hostname: Option<ObservationField<ConfiguredContainerHostname>>,
pod_membership: Option<ObservationField<NativeResourceReference>>,
native_dependencies: Option<ObservationField<Vec<NativeResourceReference>>>,
mounts: Option<ObservationField<Vec<ContainerMountObservation>>>,
secret_grants: Option<ObservationField<Vec<ContainerSecretGrantObservation>>>,
image_repo_tags: ObservationField<Vec<String>>,
network: Option<NetworkDecoded>,
memory_swappiness: Option<ObservationField<u64>>,
configured_image: Option<ObservationField<String>>,
container_b3: Option<ContainerB3Decoded>,
local_image_id: Option<ObservationField<String>>,
is_infra: Option<ObservationField<bool>>,
container_networking: Option<ObservationField<NativeNetworkingObservation>>,
pod_create_infra: Option<ObservationField<bool>>,
pod_networking: Option<ObservationField<NativeNetworkingObservation>>,
secret_driver: Option<ObservationField<NativeSecretDriverObservation>>,
volume_owner_user: Option<ObservationField<VolumeOwnerIdWireValue>>,
volume_owner_group: Option<ObservationField<VolumeOwnerIdWireValue>>,
b4: Option<B4Decoded>,
}
fn details_from_decoded(kind: ResourceKind, details: DecodedDetails) -> ResourceDetails {
match kind {
ResourceKind::Container => container_details_from_decoded(details),
ResourceKind::Pod => pod_details_from_decoded(details),
ResourceKind::Network => network_details_from_decoded(details),
ResourceKind::Volume => volume_details_from_decoded(details),
ResourceKind::Image => image_details_from_decoded(details),
ResourceKind::Secret => secret_details_from_decoded(details),
}
}
fn container_details_from_decoded(details: DecodedDetails) -> ResourceDetails {
let ContainerB3Decoded {
creation_evidence,
restart_policy,
health_check,
health_failure_action,
startup_health_check,
logging,
security,
namespaces,
resource_controls,
unmodelled: _,
} = details.container_b3.unwrap_or_default();
ResourceDetails::Container(ContainerObservation::new(
details.labels,
details.configured_image.unwrap_or(ObservationField::NotApplicable),
details.local_image_id.unwrap_or(ObservationField::NotApplicable),
details.relationships,
details.environment,
details.command.unwrap_or(ObservationField::NotApplicable),
details.entrypoint.unwrap_or(ObservationField::NotApplicable),
details.user.unwrap_or(ObservationField::NotApplicable),
details.working_directory.unwrap_or(ObservationField::NotApplicable),
details.hostname.unwrap_or(ObservationField::NotApplicable),
details.pod_membership.unwrap_or(ObservationField::Absent),
details.native_dependencies.unwrap_or(ObservationField::Absent),
details.mounts.unwrap_or(ObservationField::Absent),
details.secret_grants.unwrap_or(ObservationField::Absent),
details.memory_swappiness.unwrap_or(ObservationField::NotApplicable),
details.is_infra.unwrap_or(ObservationField::Absent),
restart_policy,
health_check,
health_failure_action,
startup_health_check,
logging,
security,
namespaces,
resource_controls,
details.container_networking.unwrap_or(ObservationField::Absent),
creation_evidence,
))
}
fn pod_details_from_decoded(details: DecodedDetails) -> ResourceDetails {
ResourceDetails::Pod(PodObservation::new(
details.labels,
details.relationships,
details.pod_create_infra.unwrap_or(ObservationField::Absent),
details.pod_networking.unwrap_or(ObservationField::Absent),
))
}
fn network_details_from_decoded(details: DecodedDetails) -> ResourceDetails {
let (internal, options, subnets, routes) = details.network.unwrap_or((
ObservationField::NotApplicable,
ObservationField::NotApplicable,
ObservationField::NotApplicable,
ObservationField::NotApplicable,
));
ResourceDetails::Network(NetworkObservation::new(
details.labels,
internal,
options,
subnets,
routes,
))
}
fn volume_details_from_decoded(details: DecodedDetails) -> ResourceDetails {
let (driver, created_at, anonymous) = match details.b4 {
Some(B4Decoded::Volume {
driver,
created_at,
anonymous,
}) => (driver, created_at, anonymous),
_ => (
ObservationField::Malformed,
ObservationField::Malformed,
ObservationField::Malformed,
),
};
ResourceDetails::Volume(VolumeObservation::new(
details.labels,
details.volume_owner_user.unwrap_or(ObservationField::Malformed),
details.volume_owner_group.unwrap_or(ObservationField::Malformed),
driver,
created_at,
anonymous,
))
}
fn image_details_from_decoded(details: DecodedDetails) -> ResourceDetails {
let metadata = match details.b4 {
Some(B4Decoded::Image {
repo_digests,
digest,
created,
author,
architecture,
operating_system,
manifest_type,
}) => ImageObservationFields {
labels: details.labels,
repo_tags: details.image_repo_tags,
repo_digests,
environment: details.environment,
digest,
created,
author,
architecture,
operating_system,
manifest_type,
},
_ => ImageObservationFields {
labels: details.labels,
repo_tags: details.image_repo_tags,
repo_digests: ObservationField::Malformed,
environment: details.environment,
digest: ObservationField::Malformed,
created: ObservationField::Malformed,
author: ObservationField::Malformed,
architecture: ObservationField::Malformed,
operating_system: ObservationField::Malformed,
manifest_type: ObservationField::Malformed,
},
};
ResourceDetails::Image(ImageObservation::new(metadata))
}
fn secret_details_from_decoded(details: DecodedDetails) -> ResourceDetails {
let (driver, created_at, updated_at) = match details.b4 {
Some(B4Decoded::Secret { created_at, updated_at }) => (
details.secret_driver.unwrap_or(ObservationField::Absent),
created_at,
updated_at,
),
_ => (
details.secret_driver.unwrap_or(ObservationField::Malformed),
ObservationField::Malformed,
ObservationField::Malformed,
),
};
ResourceDetails::Secret(SecretObservation::new(details.labels, driver, created_at, updated_at))
}
fn partial_observation(
identity: ResourceIdentity,
evidence: ResourceEvidence,
finding: DiagnosticCode,
) -> ResourceObservation {
ResourceObservation::incomplete(ObservationHeader::incomplete(
identity.clone(),
evidence,
if finding == DiagnosticCode::ResourceUnavailable {
ResourceObservationState::Unavailable
} else {
ResourceObservationState::Malformed
},
vec![InventoryFinding::for_resource(finding, identity)],
))
}
#[allow(clippy::too_many_lines)] fn decode_container(
listed: &ResourceIdentity,
object: &Map<String, Value>,
options: AcquisitionOptions,
evidence: &ResourceEvidence,
) -> PodmanLensResult<Decoded> {
let identity = identity_from_inspect(listed, object, &["Id"], &["Name"])?;
let mut findings = Vec::new();
let configuration = decode_container_configuration(object, options, &identity, &mut findings);
let labels = configuration.labels;
let environment = configuration.environment;
let mut relationships = Vec::new();
let local_image_id = optional_string_field(
object.get("Image"),
"$.Image",
&identity,
ObservationOrigin::LocalResolution,
&mut findings,
);
let configured_image = optional_string_field(
object.get("ImageName"),
"$.ImageName",
&identity,
ObservationOrigin::Configured,
&mut findings,
);
append_configured_image_relationship(&configured_image, &mut relationships);
let mut relationship_decoding = RelationshipDecoding::from_field(&configured_image);
let pod_membership = decode_native_reference(object.get("Pod"), "$.Pod", &identity, &mut findings);
relationship_decoding.merge(append_native_reference_relationship(
&pod_membership,
ResourceKind::Pod,
&mut relationships,
));
let container_networks = if matches!(pod_membership, ObservationField::Absent) {
decode_container_networks(object, &identity, &mut relationships, &mut findings)
} else {
ContainerNetworksDecoded {
field: ObservationField::NotApplicable,
relationships: RelationshipDecoding::default(),
}
};
relationship_decoding.merge(container_networks.relationships);
let container_networking = decode_container_networking(
object,
&pod_membership,
container_networks.field,
&identity,
&mut findings,
);
let mounts = decode_container_mounts(object, &identity, &mut relationships, &mut findings);
relationship_decoding.merge(mounts.relationships);
let creation_evidence = decode_container_creation_evidence(
container_create_command(object),
&configured_image,
&local_image_id,
&mounts.field,
&identity,
&mut findings,
);
let native_dependencies = decode_native_dependencies(object.get("Dependencies"), &identity, &mut findings);
relationship_decoding.merge(append_native_dependency_relationships(
&native_dependencies,
&mut relationships,
));
let secret_grants =
decode_container_secret_grants(object.get("Config"), &identity, &mut relationships, &mut findings);
relationship_decoding.merge(secret_grants.relationships);
let memory_swappiness = decode_memory_swappiness(object, evidence, &identity, &mut findings);
let mut container_b3 = decode_container_b3(object, &identity, &mut findings);
container_b3.creation_evidence = creation_evidence;
let is_infra = decode_is_infra(object, &identity, &mut findings);
Ok((
identity,
labels,
relationship_field(relationships, relationship_decoding),
environment,
Some(configuration.command),
Some(configuration.entrypoint),
Some(configuration.user),
Some(configuration.working_directory),
Some(configuration.hostname),
Some(pod_membership),
Some(native_dependencies),
Some(mounts.field),
Some(secret_grants.field),
ObservationField::NotApplicable,
None,
Some(memory_swappiness),
Some(configured_image),
Some(local_image_id),
Some(is_infra),
Some(container_networking),
None,
None,
None,
None,
None,
Some(container_b3),
None,
findings,
&[
"Id",
"Name",
"Image",
"ImageName",
"Pod",
"NetworkSettings",
"Mounts",
"Dependencies",
"Config",
"HostConfig",
"IsInfra",
],
))
}
const MAX_AUTHORED_MOUNT_RELABELS: usize = 64;
const MAX_CREATE_COMMAND_ARGUMENTS: usize = 256;
const MAX_CREATE_COMMAND_ARGUMENT_BYTES: usize = 16 * 1024;
const MAX_CREATE_COMMAND_ARGUMENT_BYTES_PER_VALUE: usize = 4 * 1024;
fn container_create_command(object: &Map<String, Value>) -> Option<&Value> {
match object.get("Config") {
None | Some(Value::Null) => None,
Some(Value::Object(config)) => config.get("CreateCommand"),
Some(value) => Some(value),
}
}
struct ParsedCreateMount<'a> {
source: &'a str,
destination: &'a str,
relabel: ContainerMountSelinuxRelabel,
}
struct ParsedCreateCommand<'a> {
image: &'a str,
mount_relabels: ParsedCreateMountRelabels<'a>,
}
enum ParsedCreateMountRelabels<'a> {
Observed(Vec<ParsedCreateMount<'a>>),
Malformed,
Unavailable,
}
enum ParsedCreateMountRelabel<'a> {
Irrelevant,
Observed(ParsedCreateMount<'a>),
Malformed,
Unavailable,
}
impl<'a> ParsedCreateMountRelabels<'a> {
fn record(&mut self, parsed: ParsedCreateMountRelabel<'a>) {
match parsed {
ParsedCreateMountRelabel::Irrelevant => {}
ParsedCreateMountRelabel::Observed(mount) => {
let Self::Observed(mounts) = self else {
return;
};
if mounts.len() >= MAX_AUTHORED_MOUNT_RELABELS {
*self = Self::Unavailable;
} else {
mounts.push(mount);
}
}
ParsedCreateMountRelabel::Malformed => *self = Self::Malformed,
ParsedCreateMountRelabel::Unavailable => {
if !matches!(self, Self::Malformed) {
*self = Self::Unavailable;
}
}
}
}
}
enum CreateCommandParse<'a> {
Absent,
Malformed,
Unavailable,
Parsed(ParsedCreateCommand<'a>),
}
fn decode_container_creation_evidence(
value: Option<&Value>,
configured_image: &ObservationField<String>,
local_image_id: &ObservationField<String>,
mounts: &ObservationField<Vec<ContainerMountObservation>>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<ContainerCreationEvidence> {
let parsed = match parse_create_command(value) {
CreateCommandParse::Absent => return ObservationField::Absent,
CreateCommandParse::Malformed => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config.CreateCommand",
));
return ObservationField::Malformed;
}
CreateCommandParse::Unavailable => return ObservationField::Unavailable,
CreateCommandParse::Parsed(parsed) => parsed,
};
let image = decode_authored_image_spelling_hint(parsed.image, configured_image, local_image_id, identity, findings);
let mount_relabels = match parsed.mount_relabels {
ParsedCreateMountRelabels::Observed(parsed_mounts) => {
correlate_authored_mount_relabels(parsed_mounts, mounts, identity, findings)
}
ParsedCreateMountRelabels::Malformed => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config.CreateCommand",
));
ObservationField::Malformed
}
ParsedCreateMountRelabels::Unavailable => ObservationField::Unavailable,
};
ObservationField::Observed(ObservedValue::new(
ContainerCreationEvidence::new(image, mount_relabels),
ObservationOrigin::Configured,
))
}
fn correlate_authored_mount_relabels(
parsed_mounts: Vec<ParsedCreateMount<'_>>,
mounts: &ObservationField<Vec<ContainerMountObservation>>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<AuthoredMountRelabelHint>> {
if parsed_mounts.is_empty() {
return configured_creation_hint(Vec::new());
}
let Some(inspect_mounts) = mounts.observed().map(ObservedValue::value) else {
return ObservationField::Unavailable;
};
let mut relabels = Vec::with_capacity(parsed_mounts.len());
for candidate in parsed_mounts {
let mut matching = inspect_mounts.iter().enumerate().filter(|(_, mount)| {
mount
.source()
.observed()
.is_some_and(|source| source.value().value() == candidate.source)
&& mount
.destination()
.observed()
.is_some_and(|destination| destination.value() == candidate.destination)
});
let Some((mount_index, mount)) = matching.next() else {
return ObservationField::Unavailable;
};
if matching.next().is_some() {
return ObservationField::Unavailable;
}
let hint = match mount.selinux_relabel() {
ObservationField::Observed(relabel) if *relabel.value() == candidate.relabel => match candidate.relabel {
ContainerMountSelinuxRelabel::Shared => AuthoredMountRelabelHint::Shared { mount_index },
ContainerMountSelinuxRelabel::Private => AuthoredMountRelabelHint::Private { mount_index },
},
ObservationField::Observed(_) => {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::CreationEvidenceConflict,
identity.clone(),
"$.Config.CreateCommand",
mount_index,
));
AuthoredMountRelabelHint::Contradictory { mount_index }
}
_ => return ObservationField::Unavailable,
};
relabels.push(hint);
}
configured_creation_hint(relabels)
}
fn configured_creation_hint<T>(value: T) -> ObservationField<T> {
ObservationField::Observed(ObservedValue::new(value, ObservationOrigin::Configured))
}
fn decode_authored_image_spelling_hint(
parsed_image: &str,
configured_image: &ObservationField<String>,
local_image_id: &ObservationField<String>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<AuthoredImageSpellingHint> {
let configured = configured_image.observed();
let local = local_image_id.observed();
if configured.is_some_and(|value| value.value() == parsed_image) {
return configured_creation_hint(AuthoredImageSpellingHint::MatchesConfiguredImage);
}
if local.is_some_and(|value| value.value() == parsed_image) {
return configured_creation_hint(AuthoredImageSpellingHint::MatchesLocalImageId);
}
if configured.is_none() || local.is_none() {
return ObservationField::Unavailable;
}
findings.push(InventoryFinding::field(
DiagnosticCode::CreationEvidenceConflict,
identity.clone(),
"$.Config.CreateCommand",
));
configured_creation_hint(AuthoredImageSpellingHint::Contradictory)
}
fn bounded_create_command_values(value: Option<&Value>) -> Result<Option<Vec<&str>>, CreateCommandParse<'static>> {
let Some(value) = value else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(arguments) = value.as_array() else {
return Err(CreateCommandParse::Malformed);
};
if arguments.len() > MAX_CREATE_COMMAND_ARGUMENTS {
return Err(CreateCommandParse::Unavailable);
}
let mut values = Vec::with_capacity(arguments.len());
let mut bytes = 0;
for argument in arguments {
let Some(argument) = argument.as_str() else {
return Err(CreateCommandParse::Malformed);
};
if argument.len() > MAX_CREATE_COMMAND_ARGUMENT_BYTES_PER_VALUE {
return Err(CreateCommandParse::Unavailable);
}
bytes += argument.len();
if bytes > MAX_CREATE_COMMAND_ARGUMENT_BYTES {
return Err(CreateCommandParse::Unavailable);
}
values.push(argument);
}
Ok(Some(values))
}
fn parse_create_command(value: Option<&Value>) -> CreateCommandParse<'_> {
let values = match bounded_create_command_values(value) {
Ok(Some(values)) => values,
Ok(None) => return CreateCommandParse::Absent,
Err(state) => return state,
};
let Some((first, remaining)) = values.split_first() else {
return CreateCommandParse::Malformed;
};
if !matches!(*first, "podman") && !first.ends_with("/podman") {
return CreateCommandParse::Unavailable;
}
let mut index = 0;
if remaining.get(index).is_some_and(|value| *value == "container") {
index += 1;
}
if !matches!(remaining.get(index), Some(&"create" | &"run")) {
return CreateCommandParse::Unavailable;
}
index += 1;
let mut mount_relabels = ParsedCreateMountRelabels::Observed(Vec::new());
while let Some(argument) = remaining.get(index) {
if *argument == "--" {
index += 1;
break;
}
if !argument.starts_with('-') || *argument == "-" {
break;
}
let argument = *argument;
let option_value = if let Some(value) = argument.strip_prefix("--volume=") {
Some(("volume", value))
} else if let Some(value) = argument.strip_prefix("--mount=") {
Some(("mount", value))
} else if let Some(value) = argument.strip_prefix("-v") {
(!value.is_empty()).then_some(("volume", value))
} else {
None
};
if let Some((kind, value)) = option_value {
mount_relabels.record(parse_create_mount_relabel(kind, value));
index += 1;
continue;
}
if matches!(argument, "--volume" | "-v" | "--mount") {
let Some(value) = remaining.get(index + 1) else {
return CreateCommandParse::Malformed;
};
let kind = if argument == "--mount" { "mount" } else { "volume" };
mount_relabels.record(parse_create_mount_relabel(kind, value));
index += 2;
continue;
}
if is_create_flag_without_value(argument) {
index += 1;
continue;
}
if let Some(option) = argument.strip_prefix("--") {
let (option, inline_value) = match option.split_once('=') {
Some((option, _value)) => (option, true),
None => (option, false),
};
if !is_create_flag_with_value(option) {
return CreateCommandParse::Unavailable;
}
if !inline_value && remaining.get(index + 1).is_none() {
return CreateCommandParse::Malformed;
}
index += if inline_value { 1 } else { 2 };
continue;
}
return CreateCommandParse::Unavailable;
}
let Some(image) = remaining.get(index) else {
return CreateCommandParse::Malformed;
};
CreateCommandParse::Parsed(ParsedCreateCommand { image, mount_relabels })
}
fn is_create_flag_without_value(option: &str) -> bool {
matches!(
option,
"-d" | "-i"
| "-t"
| "--detach"
| "--interactive"
| "--tty"
| "--rm"
| "--replace"
| "--privileged"
| "--read-only"
| "--init"
| "--no-hosts"
| "--http-proxy"
)
}
fn is_create_flag_with_value(option: &str) -> bool {
matches!(
option,
"name"
| "pod"
| "network"
| "network-alias"
| "publish"
| "env"
| "env-file"
| "label"
| "label-file"
| "annotation"
| "user"
| "workdir"
| "entrypoint"
| "hostname"
| "restart"
| "health-cmd"
| "health-interval"
| "health-timeout"
| "health-retries"
| "health-start-period"
| "memory"
| "memory-reservation"
| "memory-swap"
| "cpus"
| "cpu-shares"
| "cpu-period"
| "cpu-quota"
| "pids-limit"
| "ulimit"
| "security-opt"
| "cap-add"
| "cap-drop"
| "device"
| "dns"
| "dns-option"
| "dns-search"
| "add-host"
| "tmpfs"
| "expose"
| "stop-signal"
| "stop-timeout"
| "log-driver"
| "log-opt"
| "cgroup-parent"
| "cgroupns"
| "ipc"
| "pid"
| "uts"
| "userns"
| "gidmap"
| "uidmap"
| "shm-size"
| "secret"
| "credential"
| "pull"
| "authfile"
| "creds"
| "platform"
| "preserve-fd"
)
}
fn parse_create_mount_relabel<'a>(kind: &str, value: &'a str) -> ParsedCreateMountRelabel<'a> {
match kind {
"volume" => {
let mut parts = value.splitn(3, ':');
let (Some(source), Some(destination)) = (parts.next(), parts.next()) else {
return ParsedCreateMountRelabel::Malformed;
};
if source.is_empty() || destination.is_empty() {
return ParsedCreateMountRelabel::Malformed;
}
let Some(options) = parts.next() else {
return ParsedCreateMountRelabel::Irrelevant;
};
let relabel = match parse_volume_relabel(options) {
Ok(Some(relabel)) => relabel,
Ok(None) => return ParsedCreateMountRelabel::Irrelevant,
Err(()) => return ParsedCreateMountRelabel::Unavailable,
};
ParsedCreateMountRelabel::Observed(ParsedCreateMount {
source,
destination,
relabel,
})
}
"mount" => parse_structured_mount_relabel(value),
_ => ParsedCreateMountRelabel::Unavailable,
}
}
fn parse_volume_relabel(options: &str) -> Result<Option<ContainerMountSelinuxRelabel>, ()> {
let mut relabel = None;
for option in options.split(',') {
let candidate = match option {
"z" => Some(ContainerMountSelinuxRelabel::Shared),
"Z" => Some(ContainerMountSelinuxRelabel::Private),
_ => None,
};
if let Some(candidate) = candidate {
if relabel.is_some_and(|previous| previous != candidate) {
return Err(());
}
relabel = Some(candidate);
}
}
Ok(relabel)
}
fn parse_structured_mount_relabel(value: &str) -> ParsedCreateMountRelabel<'_> {
let mut source = None;
let mut destination = None;
let mut relabel = None;
for part in value.split(',') {
let Some((key, part_value)) = part.split_once('=') else {
return match part {
"relabel" | "source" | "src" | "destination" | "dst" | "target" => ParsedCreateMountRelabel::Malformed,
"ro" | "rw" | "bind-nonrecursive" | "nosuid" | "nodev" | "noexec" => {
continue;
}
_ => ParsedCreateMountRelabel::Unavailable,
};
};
match key {
"source" | "src" => {
if part_value.is_empty() {
return ParsedCreateMountRelabel::Malformed;
}
if source.replace(part_value).is_some() {
return ParsedCreateMountRelabel::Unavailable;
}
}
"destination" | "dst" | "target" => {
if part_value.is_empty() {
return ParsedCreateMountRelabel::Malformed;
}
if destination.replace(part_value).is_some() {
return ParsedCreateMountRelabel::Unavailable;
}
}
"relabel" => {
let candidate = match part_value {
"shared" => ContainerMountSelinuxRelabel::Shared,
"private" => ContainerMountSelinuxRelabel::Private,
_ => return ParsedCreateMountRelabel::Malformed,
};
if relabel.replace(candidate).is_some() {
return ParsedCreateMountRelabel::Unavailable;
}
}
"type" if part_value == "bind" => {}
_ => return ParsedCreateMountRelabel::Unavailable,
}
}
let Some(relabel) = relabel else {
return ParsedCreateMountRelabel::Irrelevant;
};
let (Some(source), Some(destination)) = (source, destination) else {
return ParsedCreateMountRelabel::Malformed;
};
ParsedCreateMountRelabel::Observed(ParsedCreateMount {
source,
destination,
relabel,
})
}
fn decode_pod(
listed: &ResourceIdentity,
object: &Map<String, Value>,
evidence: &ResourceEvidence,
) -> PodmanLensResult<Decoded> {
let identity = identity_from_inspect(listed, object, &["Id"], &["Name"])?;
let mut relationships = Vec::new();
let mut findings = Vec::new();
let labels = labels(object.get("Labels"), "$.Labels", &identity, &mut findings);
let mut relationship_decoding = decode_pod_containers(object, &identity, &mut relationships, &mut findings);
let (create_infra, networking, networking_relationships) =
decode_pod_networking(object, &identity, evidence, &mut relationships, &mut findings);
relationship_decoding.merge(networking_relationships);
Ok((
identity,
labels,
relationship_field(relationships, relationship_decoding),
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
Some(create_infra),
Some(networking),
None,
None,
None,
None,
None,
findings,
&["Id", "Name", "Labels", "Containers", "CreateInfra", "InfraConfig"],
))
}
struct ContainerConfigurationDecoded {
labels: ObservationField<Labels>,
environment: ObservationField<ProtectedEnvironment>,
command: ObservationField<ConfiguredContainerCommand>,
entrypoint: ObservationField<ConfiguredContainerEntrypoint>,
user: ObservationField<ConfiguredContainerUser>,
working_directory: ObservationField<ConfiguredContainerWorkdir>,
hostname: ObservationField<ConfiguredContainerHostname>,
}
fn decode_container_b3(
object: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ContainerB3Decoded {
let mut unmodelled = Vec::new();
let (health_check, health_failure_action, startup_health_check) = match object.get("Config") {
None | Some(Value::Null) => (
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
),
Some(Value::Object(config)) => (
decode_native_health_check(
config.get("Healthcheck"),
"$.Config.Healthcheck",
identity,
findings,
&mut unmodelled,
),
decode_native_health_failure_action(
config.get("HealthcheckOnFailureAction"),
identity,
findings,
&mut unmodelled,
),
decode_native_startup_health_check(config.get("StartupHealthCheck"), identity, findings, &mut unmodelled),
),
Some(_) => (
native_malformed_field("$.Config", identity, findings),
native_malformed_field("$.Config", identity, findings),
native_malformed_field("$.Config", identity, findings),
),
};
let (restart_policy, logging, security, namespaces, resource_controls) = match object.get("HostConfig") {
None | Some(Value::Null) => (
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
),
Some(Value::Object(host_config)) => (
decode_native_restart_policy(host_config.get("RestartPolicy"), identity, findings, &mut unmodelled),
decode_native_logging(host_config.get("LogConfig"), identity, findings, &mut unmodelled),
decode_native_security(host_config, identity, findings, &mut unmodelled),
decode_native_namespaces(host_config, identity, findings, &mut unmodelled),
decode_native_resource_controls(host_config, identity, findings),
),
Some(_) => (
native_malformed_field("$.HostConfig", identity, findings),
native_malformed_field("$.HostConfig", identity, findings),
native_malformed_field("$.HostConfig", identity, findings),
native_malformed_field("$.HostConfig", identity, findings),
native_malformed_field("$.HostConfig", identity, findings),
),
};
ContainerB3Decoded {
creation_evidence: ObservationField::NotApplicable,
restart_policy,
health_check,
health_failure_action,
startup_health_check,
logging,
security,
namespaces,
resource_controls,
unmodelled,
}
}
fn decode_native_restart_policy(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeRestartPolicyObservation> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(value) = value.as_object() else {
return native_malformed_field("$.HostConfig.RestartPolicy", identity, findings);
};
let name = match value.get("Name") {
None | Some(Value::Null) => ObservationField::Absent,
Some(json_value @ Value::String(name)) => match name.as_str() {
"" | "no" => ObservationField::Observed(ObservedValue::new(
NativeRestartPolicyName::No,
ObservationOrigin::Effective,
)),
"always" => ObservationField::Observed(ObservedValue::new(
NativeRestartPolicyName::Always,
ObservationOrigin::Effective,
)),
"on-failure" => ObservationField::Observed(ObservedValue::new(
NativeRestartPolicyName::OnFailure,
ObservationOrigin::Effective,
)),
"unless-stopped" => ObservationField::Observed(ObservedValue::new(
NativeRestartPolicyName::UnlessStopped,
ObservationOrigin::Effective,
)),
_ => native_unmodelled_field("$.HostConfig.RestartPolicy.Name", json_value, unmodelled),
},
Some(_) => native_malformed_field("$.HostConfig.RestartPolicy.Name", identity, findings),
};
let maximum_retry_count = native_u64_field(
value.get("MaximumRetryCount"),
"$.HostConfig.RestartPolicy.MaximumRetryCount",
identity,
ObservationOrigin::Effective,
findings,
);
ObservationField::Observed(ObservedValue::new(
NativeRestartPolicyObservation::new(name, maximum_retry_count),
ObservationOrigin::Effective,
))
}
fn decode_native_health_check(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeHealthCheckObservation> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(value) = value.as_object() else {
return native_malformed_field(path, identity, findings);
};
let command = native_health_command(
value.get("Test"),
&format!("{path}.Test"),
identity,
findings,
unmodelled,
);
let interval = native_i64_field(
value.get("Interval"),
&format!("{path}.Interval"),
identity,
ObservationOrigin::Effective,
findings,
);
let timeout = native_i64_field(
value.get("Timeout"),
&format!("{path}.Timeout"),
identity,
ObservationOrigin::Effective,
findings,
);
let retries = native_u64_field(
value.get("Retries"),
&format!("{path}.Retries"),
identity,
ObservationOrigin::Effective,
findings,
);
let start_period = native_i64_field(
value.get("StartPeriod"),
&format!("{path}.StartPeriod"),
identity,
ObservationOrigin::Effective,
findings,
);
ObservationField::Observed(ObservedValue::new(
NativeHealthCheckObservation::new(command, interval, timeout, retries, start_period),
ObservationOrigin::Effective,
))
}
fn decode_native_startup_health_check(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeStartupHealthCheckObservation> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(value) = value.as_object() else {
return native_malformed_field("$.Config.StartupHealthCheck", identity, findings);
};
let command = native_health_command(
value.get("Test"),
"$.Config.StartupHealthCheck.Test",
identity,
findings,
unmodelled,
);
let interval = native_i64_field(
value.get("Interval"),
"$.Config.StartupHealthCheck.Interval",
identity,
ObservationOrigin::Effective,
findings,
);
let timeout = native_i64_field(
value.get("Timeout"),
"$.Config.StartupHealthCheck.Timeout",
identity,
ObservationOrigin::Effective,
findings,
);
let retries = native_u64_field(
value.get("Retries"),
"$.Config.StartupHealthCheck.Retries",
identity,
ObservationOrigin::Effective,
findings,
);
let start_period = native_i64_field(
value.get("StartPeriod"),
"$.Config.StartupHealthCheck.StartPeriod",
identity,
ObservationOrigin::Effective,
findings,
);
let successes = native_u64_field(
value.get("Successes"),
"$.Config.StartupHealthCheck.Successes",
identity,
ObservationOrigin::Effective,
findings,
);
ObservationField::Observed(ObservedValue::new(
NativeStartupHealthCheckObservation::new(command, interval, timeout, retries, start_period, successes),
ObservationOrigin::Effective,
))
}
fn native_health_command(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeHealthCommand> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field(path, identity, findings);
};
let Some(values) = values.iter().map(Value::as_str).collect::<Option<Vec<_>>>() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
return ObservationField::Malformed;
};
let Some((kind, arguments)) = values.split_first() else {
return native_malformed_field(path, identity, findings);
};
let command = match *kind {
"NONE" if arguments.is_empty() => NativeHealthCommand::Disabled,
"CMD" if !arguments.is_empty() => NativeHealthCommand::Exec(ProtectedHealthCommand::new(
arguments.iter().map(ToString::to_string).collect(),
)),
"CMD-SHELL" if !arguments.is_empty() => NativeHealthCommand::Shell(ProtectedHealthCommand::new(
arguments.iter().map(ToString::to_string).collect(),
)),
"NONE" | "CMD" | "CMD-SHELL" => {
return native_malformed_field(path, identity, findings);
}
_ => return native_unmodelled_field(path, value, unmodelled),
};
ObservationField::Observed(ObservedValue::new(command, ObservationOrigin::Effective))
}
fn decode_native_health_failure_action(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeHealthFailureAction> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(action_name) = value.as_str() else {
return native_malformed_field("$.Config.HealthcheckOnFailureAction", identity, findings);
};
let action = match action_name {
"none" => NativeHealthFailureAction::None,
"kill" => NativeHealthFailureAction::Kill,
"restart" => NativeHealthFailureAction::Restart,
"stop" => NativeHealthFailureAction::Stop,
_ => {
return native_unmodelled_field("$.Config.HealthcheckOnFailureAction", value, unmodelled);
}
};
ObservationField::Observed(ObservedValue::new(action, ObservationOrigin::Effective))
}
fn decode_native_logging(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeLoggingObservation> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(value) = value.as_object() else {
return native_malformed_field("$.HostConfig.LogConfig", identity, findings);
};
let driver = match value.get("Type") {
None | Some(Value::Null) => ObservationField::Absent,
Some(json_value @ Value::String(driver)) => match driver.as_str() {
"journald" => ObservationField::Observed(ObservedValue::new(
NativeLogDriver::Journald,
ObservationOrigin::Effective,
)),
"k8s-file" => ObservationField::Observed(ObservedValue::new(
NativeLogDriver::K8sFile,
ObservationOrigin::Effective,
)),
_ => native_unmodelled_field("$.HostConfig.LogConfig.Type", json_value, unmodelled),
},
Some(_) => native_malformed_field("$.HostConfig.LogConfig.Type", identity, findings),
};
let size = native_string_field(
value.get("Size"),
"$.HostConfig.LogConfig.Size",
identity,
ObservationOrigin::Effective,
findings,
);
ObservationField::Observed(ObservedValue::new(
NativeLoggingObservation::new(driver, size),
ObservationOrigin::Effective,
))
}
fn decode_native_security(
host_config: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeSecurityObservation> {
let privileged = native_bool_field(
host_config.get("Privileged"),
"$.HostConfig.Privileged",
identity,
ObservationOrigin::Effective,
findings,
);
let cap_add = native_capabilities(
host_config.get("CapAdd"),
"$.HostConfig.CapAdd",
identity,
findings,
unmodelled,
);
let cap_drop = native_capabilities(
host_config.get("CapDrop"),
"$.HostConfig.CapDrop",
identity,
findings,
unmodelled,
);
let security_options = native_security_options(host_config.get("SecurityOpt"), identity, findings);
let read_only_root_filesystem = native_bool_field(
host_config.get("ReadonlyRootfs"),
"$.HostConfig.ReadonlyRootfs",
identity,
ObservationOrigin::Effective,
findings,
);
ObservationField::Observed(ObservedValue::new(
NativeSecurityObservation::new(
privileged,
cap_add,
cap_drop,
security_options,
read_only_root_filesystem,
),
ObservationOrigin::Effective,
))
}
fn native_capabilities(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<Vec<NativeCapability>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field(path, identity, findings);
};
let mut decoded = Vec::with_capacity(values.len());
let mut unknown = false;
for (index, value) in values.iter().enumerate() {
let Some(capability) = value.as_str() else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
index,
));
return ObservationField::Malformed;
};
let Some(semantic) = capability.strip_prefix("CAP_") else {
unmodelled.push((format!("{path}[{index}]"), JsonValueKind::String));
unknown = true;
continue;
};
if NATIVE_CAPABILITIES.contains(&semantic) {
decoded.push(NativeCapability::new(capability.to_owned()));
} else {
unmodelled.push((format!("{path}[{index}]"), JsonValueKind::String));
unknown = true;
}
}
if unknown {
ObservationField::Unmodelled(crate::UnmodelledFieldId::ContainerHostConfig)
} else {
ObservationField::Observed(ObservedValue::new(decoded, ObservationOrigin::Effective))
}
}
const NATIVE_CAPABILITIES: &[&str] = &[
"AUDIT_CONTROL",
"AUDIT_READ",
"AUDIT_WRITE",
"BLOCK_SUSPEND",
"BPF",
"CHECKPOINT_RESTORE",
"CHOWN",
"DAC_OVERRIDE",
"DAC_READ_SEARCH",
"FOWNER",
"FSETID",
"IPC_LOCK",
"IPC_OWNER",
"KILL",
"LEASE",
"LINUX_IMMUTABLE",
"MAC_ADMIN",
"MAC_OVERRIDE",
"MKNOD",
"NET_ADMIN",
"NET_BIND_SERVICE",
"NET_BROADCAST",
"NET_RAW",
"PERFMON",
"SETFCAP",
"SETGID",
"SETPCAP",
"SETUID",
"SYS_ADMIN",
"SYS_BOOT",
"SYS_CHROOT",
"SYS_MODULE",
"SYS_NICE",
"SYS_PACCT",
"SYS_PTRACE",
"SYS_RAWIO",
"SYS_RESOURCE",
"SYS_TIME",
"SYS_TTY_CONFIG",
"SYSLOG",
"WAKE_ALARM",
];
fn native_security_options(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeOpaqueSecurityOptions> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field("$.HostConfig.SecurityOpt", identity, findings);
};
if let Some(index) = values.iter().position(|value| !value.is_string()) {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.SecurityOpt",
index,
));
return ObservationField::Malformed;
}
ObservationField::Observed(ObservedValue::new(
NativeOpaqueSecurityOptions::new(values.len()),
ObservationOrigin::Effective,
))
}
fn decode_native_namespaces(
host_config: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeNamespaceObservation> {
let pid = native_namespace_mode(
host_config.get("PidMode"),
"$.HostConfig.PidMode",
identity,
findings,
unmodelled,
true,
);
let ipc = native_ipc_namespace_mode(host_config.get("IpcMode"), identity, findings, unmodelled);
let uts = native_namespace_mode(
host_config.get("UTSMode"),
"$.HostConfig.UTSMode",
identity,
findings,
unmodelled,
true,
);
let cgroup = native_namespace_mode(
host_config.get("CgroupMode"),
"$.HostConfig.CgroupMode",
identity,
findings,
unmodelled,
false,
);
ObservationField::Observed(ObservedValue::new(
NativeNamespaceObservation::new(pid, ipc, uts, cgroup),
ObservationOrigin::Effective,
))
}
fn native_namespace_mode(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
empty_is_private: bool,
) -> ObservationField<NativeNamespaceMode> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(mode) = value.as_str() else {
return native_malformed_field(path, identity, findings);
};
let mode = match mode {
"" if empty_is_private => NativeNamespaceMode::Private,
"private" => NativeNamespaceMode::Private,
"host" => NativeNamespaceMode::Host,
_ if native_mode_is_syntactically_valid(mode) => {
return native_unmodelled_field(path, value, unmodelled);
}
_ => return native_malformed_field(path, identity, findings),
};
ObservationField::Observed(ObservedValue::new(mode, ObservationOrigin::Effective))
}
fn native_ipc_namespace_mode(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<NativeIpcNamespaceMode> {
let path = "$.HostConfig.IpcMode";
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(mode) = value.as_str() else {
return native_malformed_field(path, identity, findings);
};
let mode = match mode {
"" | "private" => NativeIpcNamespaceMode::Private,
"host" => NativeIpcNamespaceMode::Host,
"shareable" => NativeIpcNamespaceMode::Shareable,
"none" => NativeIpcNamespaceMode::None,
_ if native_mode_is_syntactically_valid(mode) => {
return native_unmodelled_field(path, value, unmodelled);
}
_ => return native_malformed_field(path, identity, findings),
};
ObservationField::Observed(ObservedValue::new(mode, ObservationOrigin::Effective))
}
fn native_mode_is_syntactically_valid(value: &str) -> bool {
!value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
}
fn decode_native_resource_controls(
host_config: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeResourceControlObservation> {
let cpu_shares = native_u64_field(
host_config.get("CpuShares"),
"$.HostConfig.CpuShares",
identity,
ObservationOrigin::Effective,
findings,
);
let cpu_period = native_u64_field(
host_config.get("CpuPeriod"),
"$.HostConfig.CpuPeriod",
identity,
ObservationOrigin::Effective,
findings,
);
let cpu_quota = native_i64_field(
host_config.get("CpuQuota"),
"$.HostConfig.CpuQuota",
identity,
ObservationOrigin::Effective,
findings,
);
let memory = native_i64_field(
host_config.get("Memory"),
"$.HostConfig.Memory",
identity,
ObservationOrigin::Effective,
findings,
);
let pids_limit = native_i64_field(
host_config.get("PidsLimit"),
"$.HostConfig.PidsLimit",
identity,
ObservationOrigin::Effective,
findings,
);
let ulimits = decode_native_ulimits(host_config.get("Ulimits"), identity, findings);
ObservationField::Observed(ObservedValue::new(
NativeResourceControlObservation::new(cpu_shares, cpu_period, cpu_quota, memory, pids_limit, ulimits),
ObservationOrigin::Effective,
))
}
fn decode_native_ulimits(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<NativeUlimitObservation>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field("$.HostConfig.Ulimits", identity, findings);
};
let mut decoded = Vec::with_capacity(values.len());
let mut malformed = false;
for (index, value) in values.iter().enumerate() {
let Some(value) = value.as_object() else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Ulimits",
index,
));
malformed = true;
continue;
};
let path = format!("$.HostConfig.Ulimits[{index}]");
let name = native_string_field(
value.get("Name"),
&format!("{path}.Name"),
identity,
ObservationOrigin::Effective,
findings,
);
let soft = native_i64_field(
value.get("Soft"),
&format!("{path}.Soft"),
identity,
ObservationOrigin::Effective,
findings,
);
let hard = native_i64_field(
value.get("Hard"),
&format!("{path}.Hard"),
identity,
ObservationOrigin::Effective,
findings,
);
if name.is_malformed() || soft.is_malformed() || hard.is_malformed() {
malformed = true;
continue;
}
decoded.push(NativeUlimitObservation::new(name, soft, hard));
}
if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(decoded, ObservationOrigin::Effective))
}
}
fn native_i64_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<i64> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(value) => value.as_i64().map_or_else(
|| native_malformed_field(path, identity, findings),
|value| ObservationField::Observed(ObservedValue::new(value, origin)),
),
}
}
fn native_u64_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<u64> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(value) => value.as_u64().map_or_else(
|| native_malformed_field(path, identity, findings),
|value| ObservationField::Observed(ObservedValue::new(value, origin)),
),
}
}
fn native_string_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<String> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) => ObservationField::Observed(ObservedValue::new(value.clone(), origin)),
Some(_) => native_malformed_field(path, identity, findings),
}
}
fn native_unmodelled_field<T>(
path: &str,
value: &Value,
unmodelled: &mut Vec<(String, JsonValueKind)>,
) -> ObservationField<T> {
unmodelled.push((path.to_owned(), json_value_kind(value)));
ObservationField::Unmodelled(if path.starts_with("$.Config") {
crate::UnmodelledFieldId::ContainerConfig
} else {
crate::UnmodelledFieldId::ContainerHostConfig
})
}
fn decode_container_configuration(
object: &Map<String, Value>,
options: AcquisitionOptions,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ContainerConfigurationDecoded {
let config = match object.get("Config") {
None | Some(Value::Null) => {
return ContainerConfigurationDecoded {
labels: ObservationField::Absent,
environment: ObservationField::Absent,
command: ObservationField::Absent,
entrypoint: ObservationField::Absent,
user: ObservationField::Absent,
working_directory: ObservationField::Absent,
hostname: ObservationField::Absent,
};
}
Some(Value::Object(config)) => config,
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config",
));
return ContainerConfigurationDecoded {
labels: ObservationField::Malformed,
environment: ObservationField::Malformed,
command: ObservationField::Malformed,
entrypoint: ObservationField::Malformed,
user: ObservationField::Malformed,
working_directory: ObservationField::Malformed,
hostname: ObservationField::Malformed,
};
}
};
let labels = labels(config.get("Labels"), "$.Config.Labels", identity, findings);
let (environment, environment_findings) =
decode_environment(config.get("Env"), options.environment_values, identity, "$.Config.Env");
findings.extend(environment_findings);
ContainerConfigurationDecoded {
labels,
environment,
command: decode_configured_arguments(
config.get("Cmd"),
"$.Config.Cmd",
identity,
findings,
ConfiguredContainerCommand::new,
),
entrypoint: decode_configured_entrypoint(config.get("Entrypoint"), "$.Config.Entrypoint", identity, findings),
user: decode_configured_text(
config.get("User"),
"$.Config.User",
identity,
findings,
ConfiguredContainerUser::new,
),
working_directory: decode_configured_text(
config.get("WorkingDir"),
"$.Config.WorkingDir",
identity,
findings,
ConfiguredContainerWorkdir::new,
),
hostname: decode_configured_text(
config.get("Hostname"),
"$.Config.Hostname",
identity,
findings,
ConfiguredContainerHostname::new,
),
}
}
fn decode_configured_entrypoint(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<ConfiguredContainerEntrypoint> {
if matches!(value, Some(Value::String(entrypoint)) if entrypoint.is_empty()) {
return ObservationField::Absent;
}
decode_configured_arguments(value, path, identity, findings, ConfiguredContainerEntrypoint::new)
}
#[allow(clippy::single_match_else)] fn decode_configured_arguments<T>(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
constructor: impl FnOnce(Vec<String>) -> T,
) -> ObservationField<T> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Array(values)) if values.is_empty() => ObservationField::Absent,
Some(Value::Array(values)) => {
let arguments = values.iter().map(Value::as_str).collect::<Option<Vec<_>>>();
match arguments {
Some(arguments) => ObservationField::Observed(ObservedValue::new(
constructor(arguments.into_iter().map(ToOwned::to_owned).collect()),
ObservationOrigin::Configured,
)),
None => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
fn decode_configured_text<T>(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
constructor: impl FnOnce(String) -> T,
) -> ObservationField<T> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) => ObservationField::Observed(ObservedValue::new(
constructor(value.clone()),
ObservationOrigin::Configured,
)),
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
fn decode_network(
listed: &ResourceIdentity,
object: &Map<String, Value>,
evidence: &ResourceEvidence,
) -> PodmanLensResult<Decoded> {
let identity = identity_from_inspect(listed, object, &["id"], &["name"])?;
let (network, mut findings) = decode_network_details(object, &identity, evidence);
let labels = labels(object.get("labels"), "$.labels", &identity, &mut findings);
Ok((
identity,
labels,
ObservationField::NotApplicable,
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ObservationField::NotApplicable,
Some(network),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
findings,
&["id", "name", "labels", "internal", "options", "subnets", "routes"],
))
}
fn decode_network_details(
object: &Map<String, Value>,
identity: &ResourceIdentity,
evidence: &ResourceEvidence,
) -> (NetworkDecoded, Vec<InventoryFinding>) {
let mut findings = Vec::new();
let internal = match object.get("internal") {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Bool(value)) => {
ObservationField::Observed(ObservedValue::new(*value, ObservationOrigin::Effective))
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.internal",
));
ObservationField::Malformed
}
};
let options = match string_map(object.get("options")) {
Ok(Some(options)) => ObservationField::Observed(ObservedValue::new(
NetworkOptionKeys::new(options.into_keys()),
ObservationOrigin::Effective,
)),
Ok(None) => ObservationField::Absent,
Err(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.options",
));
ObservationField::Malformed
}
};
let subnets = decode_native_network_subnets(object.get("subnets"), identity, &mut findings);
let routes = decode_native_network_routes(object.get("routes"), identity, evidence, &mut findings);
((internal, options, subnets, routes), findings)
}
fn decode_native_network_subnets(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<NativeNetworkSubnetObservation>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.subnets",
));
return ObservationField::Malformed;
};
let mut decoded = Vec::with_capacity(values.len());
let mut malformed = false;
for (index, value) in values.iter().enumerate() {
let path = format!("$.subnets[{index}]");
let Some(object) = value.as_object() else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.subnets",
index,
));
continue;
};
let cidr = native_cidr_field(
object.get("subnet"),
&format!("{path}.subnet"),
identity,
findings,
true,
);
let gateway = native_ip_field(
object.get("gateway"),
&format!("{path}.gateway"),
identity,
ObservationOrigin::Effective,
findings,
);
let lease_range = native_lease_range_field(
object.get("lease_range"),
cidr.observed().map(ObservedValue::value),
&format!("{path}.lease_range"),
identity,
findings,
);
let member_malformed = cidr.is_malformed() || gateway.is_malformed() || lease_range.is_malformed();
let gateway_outside = matches!(
(&cidr, &gateway),
(ObservationField::Observed(cidr), ObservationField::Observed(gateway))
if !cidr.value().contains(*gateway.value())
);
if gateway_outside {
malformed = true;
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("{path}.gateway"),
));
}
if member_malformed || gateway_outside {
malformed = true;
continue;
}
decoded.push(NativeNetworkSubnetObservation::new(cidr, gateway, lease_range));
}
if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(decoded, ObservationOrigin::Effective))
}
}
fn decode_native_network_routes(
value: Option<&Value>,
identity: &ResourceIdentity,
evidence: &ResourceEvidence,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<NativeNetworkRouteObservation>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.routes",
));
return ObservationField::Malformed;
};
let mut decoded = Vec::with_capacity(values.len());
let mut malformed = false;
for (index, value) in values.iter().enumerate() {
let path = format!("$.routes[{index}]");
let Some(object) = value.as_object() else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.routes",
index,
));
continue;
};
let destination = native_cidr_field(
object.get("destination"),
&format!("{path}.destination"),
identity,
findings,
true,
);
let gateway = native_ip_field(
object.get("gateway"),
&format!("{path}.gateway"),
identity,
ObservationOrigin::Effective,
findings,
);
let metric = native_u32_field(object.get("metric"), &format!("{path}.metric"), identity, findings);
let route_type = native_route_type_field(
object.get("route_type"),
&format!("{path}.route_type"),
identity,
evidence,
findings,
);
let member_malformed =
destination.is_malformed() || gateway.is_malformed() || metric.is_malformed() || route_type.is_malformed();
let gateway_wrong_family = matches!(
(&destination, &gateway),
(ObservationField::Observed(destination), ObservationField::Observed(gateway))
if !destination.value().has_address_family(*gateway.value())
);
let invalid_gateway_semantics = match (&route_type, &gateway) {
(ObservationField::Observed(route_type), gateway) => match route_type.value() {
NativeNetworkRouteType::Unicast => !gateway.is_observed(),
NativeNetworkRouteType::Blackhole
| NativeNetworkRouteType::Unreachable
| NativeNetworkRouteType::Prohibit => gateway.is_observed(),
},
(ObservationField::VersionInapplicable, gateway) => !gateway.is_observed(),
_ => false,
};
if gateway_wrong_family || invalid_gateway_semantics {
malformed = true;
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("{path}.gateway"),
));
}
if member_malformed || gateway_wrong_family || invalid_gateway_semantics {
malformed = true;
continue;
}
decoded.push(NativeNetworkRouteObservation::new(
destination,
gateway,
metric,
route_type,
));
}
if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(decoded, ObservationOrigin::Effective))
}
}
fn native_cidr_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
required: bool,
) -> ObservationField<NativeNetworkCidr> {
match value {
None | Some(Value::Null) if !required => ObservationField::Absent,
Some(Value::String(value)) => match NativeNetworkCidr::parse(value.clone()) {
Some(value) => ObservationField::Observed(ObservedValue::new(value, ObservationOrigin::Effective)),
None => native_malformed_field(path, identity, findings),
},
_ => native_malformed_field(path, identity, findings),
}
}
fn native_ip_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<IpAddr> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) => match value.parse() {
Ok(value) => ObservationField::Observed(ObservedValue::new(value, origin)),
Err(_) => native_malformed_field(path, identity, findings),
},
_ => native_malformed_field(path, identity, findings),
}
}
fn native_u32_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<u32> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Number(value)) => match value.as_u64().and_then(|value| value.try_into().ok()) {
Some(value) => ObservationField::Observed(ObservedValue::new(value, ObservationOrigin::Effective)),
None => native_malformed_field(path, identity, findings),
},
_ => native_malformed_field(path, identity, findings),
}
}
fn native_lease_range_field(
value: Option<&Value>,
cidr: Option<&NativeNetworkCidr>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeNetworkLeaseRange> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Object(value)) => {
let start = native_ip_field(
value.get("start_ip"),
&format!("{path}.start_ip"),
identity,
ObservationOrigin::Effective,
findings,
);
let end = native_ip_field(
value.get("end_ip"),
&format!("{path}.end_ip"),
identity,
ObservationOrigin::Effective,
findings,
);
if start.is_malformed() || end.is_malformed() {
return ObservationField::Malformed;
}
let outside_cidr = cidr.is_some_and(|cidr| {
[start.observed(), end.observed()]
.into_iter()
.flatten()
.any(|endpoint| !cidr.contains(*endpoint.value()))
});
let reversed = matches!(
(&start, &end),
(ObservationField::Observed(start), ObservationField::Observed(end))
if !native_address_precedes_or_equals(*start.value(), *end.value())
);
if outside_cidr || reversed {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
return ObservationField::Malformed;
}
ObservationField::Observed(ObservedValue::new(
NativeNetworkLeaseRange::new(start, end),
ObservationOrigin::Effective,
))
}
_ => native_malformed_field(path, identity, findings),
}
}
fn native_route_type_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
evidence: &ResourceEvidence,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeNetworkRouteType> {
if !native_network_route_types_are_available(evidence) {
findings.push(InventoryFinding::field(
DiagnosticCode::VersionInapplicableField,
identity.clone(),
path,
));
return ObservationField::VersionInapplicable;
}
match value {
None | Some(Value::Null) => ObservationField::Observed(ObservedValue::new(
NativeNetworkRouteType::Unicast,
ObservationOrigin::Effective,
)),
Some(Value::String(value)) => {
let value = match value.as_str() {
"unicast" => NativeNetworkRouteType::Unicast,
"blackhole" => NativeNetworkRouteType::Blackhole,
"unreachable" => NativeNetworkRouteType::Unreachable,
"prohibit" => NativeNetworkRouteType::Prohibit,
_ => return ObservationField::Unmodelled(crate::UnmodelledFieldId::NetworkRoute),
};
ObservationField::Observed(ObservedValue::new(value, ObservationOrigin::Effective))
}
_ => native_malformed_field(path, identity, findings),
}
}
fn native_network_route_types_are_available(evidence: &ResourceEvidence) -> bool {
semver::Version::parse(evidence.engine_version()).is_ok_and(|version| version >= semver::Version::new(6, 0, 0))
}
fn native_malformed_field<T>(
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<T> {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
fn native_address_precedes_or_equals(start: IpAddr, end: IpAddr) -> bool {
match (start, end) {
(IpAddr::V4(start), IpAddr::V4(end)) => u32::from(start) <= u32::from(end),
(IpAddr::V6(start), IpAddr::V6(end)) => u128::from(start) <= u128::from(end),
_ => false,
}
}
fn decode_volume(listed: &ResourceIdentity, object: &Map<String, Value>) -> PodmanLensResult<Decoded> {
let identity = identity_from_inspect(listed, object, &["Name"], &["Name"])?;
let mut findings = Vec::new();
let labels = labels(object.get("Labels"), "$.Labels", &identity, &mut findings);
let uid = decode_volume_owner(object.get("UID"), "$.UID", &identity, &mut findings);
let gid = decode_volume_owner(object.get("GID"), "$.GID", &identity, &mut findings);
let driver = optional_string_field(
object.get("Driver"),
"$.Driver",
&identity,
ObservationOrigin::Effective,
&mut findings,
);
let created_at = native_timestamp_field(object.get("CreatedAt"), "$.CreatedAt", &identity, &mut findings);
let anonymous = match object.get("Anonymous") {
None => ObservationField::Observed(ObservedValue::new(false, ObservationOrigin::Effective)),
Some(Value::Bool(value)) => {
ObservationField::Observed(ObservedValue::new(*value, ObservationOrigin::Effective))
}
Some(_) => native_malformed_field("$.Anonymous", &identity, &mut findings),
};
Ok((
identity,
labels,
ObservationField::NotApplicable,
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(uid),
Some(gid),
None,
Some(B4Decoded::Volume {
driver,
created_at,
anonymous,
}),
findings,
&["Name", "Labels", "UID", "GID", "Driver", "CreatedAt", "Anonymous"],
))
}
fn decode_volume_owner(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<VolumeOwnerIdWireValue> {
match value {
None => {
findings.push(InventoryFinding::field(
DiagnosticCode::VolumeOwnerDefaultAmbiguous,
identity.clone(),
path,
));
ObservationField::Observed(ObservedValue::new(
VolumeOwnerIdWireValue::WireAbsentMayMeanZero,
ObservationOrigin::Effective,
))
}
Some(Value::Number(value)) => {
if let Some(value) = value.as_u64().and_then(|value| u32::try_from(value).ok()) {
ObservationField::Observed(ObservedValue::new(
VolumeOwnerIdWireValue::Explicit(VolumeOwnerUnixId::new(value)),
ObservationOrigin::Effective,
))
} else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
fn decode_image(
listed: &ResourceIdentity,
object: &Map<String, Value>,
options: AcquisitionOptions,
) -> PodmanLensResult<Decoded> {
let identity = identity_from_inspect(listed, object, &["Id"], &[])?;
let mut findings = Vec::new();
let labels = labels(object.get("Labels"), "$.Labels", &identity, &mut findings);
let (config, config_malformed) = match object.get("Config") {
None | Some(Value::Null) => (None, false),
Some(Value::Object(config)) => (Some(config), false),
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config",
));
(None, true)
}
};
let environment = if config_malformed {
ObservationField::Malformed
} else {
let (environment, mut environment_findings) = decode_environment(
config.and_then(|config| config.get("Env")),
options.environment_values,
&identity,
"$.Config.Env",
);
findings.append(&mut environment_findings);
environment
};
let (repo_tags, b4) = decode_image_b4(object, &identity, &mut findings);
Ok((
identity,
labels,
ObservationField::NotApplicable,
environment,
None,
None,
None,
None,
None,
None,
None,
None,
None,
repo_tags,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(b4),
findings,
&[
"Id",
"Digest",
"RepoTags",
"RepoDigests",
"Created",
"Author",
"Architecture",
"Os",
"ManifestType",
"Labels",
"Config",
],
))
}
fn decode_image_b4(
object: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> (ObservationField<Vec<String>>, B4Decoded) {
let repo_tags = image_aliases(object.get("RepoTags"), "$.RepoTags", identity, findings);
let repo_digests = image_aliases(object.get("RepoDigests"), "$.RepoDigests", identity, findings);
let digest = optional_string_field(
object.get("Digest"),
"$.Digest",
identity,
ObservationOrigin::Effective,
findings,
);
let created = native_timestamp_field(object.get("Created"), "$.Created", identity, findings);
let author = optional_string_or_empty_field(
object.get("Author"),
"$.Author",
identity,
ObservationOrigin::Configured,
findings,
);
let architecture = optional_string_field(
object.get("Architecture"),
"$.Architecture",
identity,
ObservationOrigin::Effective,
findings,
);
let operating_system = optional_string_field(
object.get("Os"),
"$.Os",
identity,
ObservationOrigin::Effective,
findings,
);
let manifest_type = optional_string_field(
object.get("ManifestType"),
"$.ManifestType",
identity,
ObservationOrigin::Effective,
findings,
);
(
repo_tags,
B4Decoded::Image {
repo_digests,
digest,
created,
author,
architecture,
operating_system,
manifest_type,
},
)
}
fn decode_secret(listed: &ResourceIdentity, object: &Map<String, Value>) -> PodmanLensResult<Decoded> {
let identity = identity_from_inspect(listed, object, &["ID"], &["Spec"])?;
let spec = object
.get("Spec")
.and_then(Value::as_object)
.ok_or_else(|| Diagnostic::new(DiagnosticCode::ResourceMalformed))?;
let mut findings = Vec::new();
let labels = labels(spec.get("Labels"), "$.Spec.Labels", &identity, &mut findings);
if object.contains_key("SecretData") || spec.contains_key("SecretData") {
findings.push(InventoryFinding::for_resource(
DiagnosticCode::SecretPayloadDiscarded,
identity.clone(),
));
}
let driver = match spec.get("Driver") {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Object(driver)) => ObservationField::Observed(ObservedValue::new(
decode_secret_driver(driver, &identity, &mut findings),
ObservationOrigin::Effective,
)),
Some(_) => native_malformed_field("$.Spec.Driver", &identity, &mut findings),
};
let created_at = native_timestamp_field(object.get("CreatedAt"), "$.CreatedAt", &identity, &mut findings);
let updated_at = native_timestamp_field(object.get("UpdatedAt"), "$.UpdatedAt", &identity, &mut findings);
Ok((
identity,
labels,
ObservationField::NotApplicable,
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
None,
None,
None,
ObservationField::NotApplicable,
None,
None,
None,
None,
None,
None,
None,
None,
Some(driver),
None,
None,
None,
Some(B4Decoded::Secret { created_at, updated_at }),
findings,
&["ID", "CreatedAt", "UpdatedAt", "Spec", "SecretData"],
))
}
fn decode_secret_driver(
driver: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> NativeSecretDriverObservation {
let name = optional_string_field(
driver.get("Name"),
"$.Spec.Driver.Name",
identity,
ObservationOrigin::Effective,
findings,
);
let options = match driver.get("Options") {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Object(options)) if options.values().all(Value::is_string) => {
ObservationField::Observed(ObservedValue::new(
NativeSecretDriverOptions::new(options.len()),
ObservationOrigin::Effective,
))
}
Some(_) => native_malformed_field("$.Spec.Driver.Options", identity, findings),
};
NativeSecretDriverObservation::new(name, options)
}
fn identity_from_inspect(
listed: &ResourceIdentity,
object: &Map<String, Value>,
id_keys: &[&str],
name_keys: &[&str],
) -> PodmanLensResult<ResourceIdentity> {
let id = required_string_any(object, id_keys)?;
if id != listed.id() {
return Err(Diagnostic::new(DiagnosticCode::ResourceMalformed));
}
let name = if listed.kind == ResourceKind::Secret {
object
.get("Spec")
.and_then(Value::as_object)
.and_then(|spec| optional_string(spec, "Name"))
} else {
optional_string_any(object, name_keys).or_else(|| listed.name())
};
Ok(ResourceIdentity::new(
listed.kind,
id.to_owned(),
name.map(ToOwned::to_owned),
))
}
#[derive(Clone, Copy, Default)]
struct RelationshipDecoding {
supplied: bool,
malformed: bool,
}
impl RelationshipDecoding {
fn from_field<T>(field: &ObservationField<T>) -> Self {
Self {
supplied: !matches!(field, ObservationField::Absent | ObservationField::NotApplicable),
malformed: matches!(field, ObservationField::Malformed),
}
}
fn merge(&mut self, other: Self) {
self.supplied |= other.supplied;
self.malformed |= other.malformed;
}
}
fn append_configured_image_relationship(
configured_image: &ObservationField<String>,
relationships: &mut Vec<NativeRelationship>,
) {
if let ObservationField::Observed(value) = configured_image {
relationships.push(NativeRelationship::new(
ResourceKind::Image,
value.value(),
"$.ImageName",
));
}
}
fn append_native_reference_relationship(
field: &ObservationField<NativeResourceReference>,
kind: ResourceKind,
relationships: &mut Vec<NativeRelationship>,
) -> RelationshipDecoding {
match field {
ObservationField::Observed(reference) => {
relationships.push(NativeRelationship::new(
kind,
reference.value().reference(),
reference.value().field_path(),
));
RelationshipDecoding {
supplied: true,
malformed: false,
}
}
ObservationField::Malformed => RelationshipDecoding {
supplied: true,
malformed: true,
},
ObservationField::Absent | ObservationField::NotApplicable => RelationshipDecoding::default(),
ObservationField::Unavailable | ObservationField::VersionInapplicable | ObservationField::Unmodelled(_) => {
RelationshipDecoding {
supplied: true,
malformed: true,
}
}
}
}
fn append_native_dependency_relationships(
field: &ObservationField<Vec<NativeResourceReference>>,
relationships: &mut Vec<NativeRelationship>,
) -> RelationshipDecoding {
match field {
ObservationField::Observed(references) => {
relationships.extend(references.value().iter().map(|reference| {
NativeRelationship::new(ResourceKind::Container, reference.reference(), reference.field_path())
}));
RelationshipDecoding {
supplied: true,
malformed: false,
}
}
ObservationField::Malformed => RelationshipDecoding {
supplied: true,
malformed: true,
},
ObservationField::Absent | ObservationField::NotApplicable => RelationshipDecoding::default(),
ObservationField::Unavailable | ObservationField::VersionInapplicable | ObservationField::Unmodelled(_) => {
RelationshipDecoding {
supplied: true,
malformed: true,
}
}
}
}
fn optional_string_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<String> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) if !value.is_empty() => {
ObservationField::Observed(ObservedValue::new(value.clone(), origin))
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
struct ContainerNetworksDecoded {
field: ObservationField<Vec<NativeResourceReference>>,
relationships: RelationshipDecoding,
}
fn decode_container_networks(
object: &Map<String, Value>,
identity: &ResourceIdentity,
relationships: &mut Vec<NativeRelationship>,
findings: &mut Vec<InventoryFinding>,
) -> ContainerNetworksDecoded {
let Some(settings) = object.get("NetworkSettings") else {
return ContainerNetworksDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
};
if settings.is_null() {
return ContainerNetworksDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
}
let Some(settings) = settings.as_object() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.NetworkSettings",
));
return ContainerNetworksDecoded {
field: ObservationField::Malformed,
relationships: RelationshipDecoding {
supplied: true,
malformed: true,
},
};
};
let Some(networks) = settings.get("Networks") else {
return ContainerNetworksDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
};
if networks.is_null() {
return ContainerNetworksDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
}
let Some(networks) = networks.as_object() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.NetworkSettings.Networks",
));
return ContainerNetworksDecoded {
field: ObservationField::Malformed,
relationships: RelationshipDecoding {
supplied: true,
malformed: true,
},
};
};
let mut malformed = false;
let mut references = Vec::with_capacity(networks.len());
for (name, details) in networks {
if name.is_empty() || !details.is_object() && !details.is_null() {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("$.NetworkSettings.Networks.{name}"),
));
malformed = true;
continue;
}
references.push(NativeResourceReference::new(
name.clone(),
format!("$.NetworkSettings.Networks.{name}"),
));
relationships.push(NativeRelationship::new(
ResourceKind::Network,
name,
format!("$.NetworkSettings.Networks.{name}"),
));
}
ContainerNetworksDecoded {
field: if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(references, ObservationOrigin::Effective))
},
relationships: RelationshipDecoding {
supplied: true,
malformed,
},
}
}
struct ContainerMountDecoded {
field: ObservationField<Vec<ContainerMountObservation>>,
relationships: RelationshipDecoding,
}
struct NativeBindSelinuxRelabel {
source: String,
destination: String,
relabel: ContainerMountSelinuxRelabel,
}
#[allow(clippy::too_many_lines, clippy::single_match_else)] fn decode_container_mounts(
object: &Map<String, Value>,
identity: &ResourceIdentity,
relationships: &mut Vec<NativeRelationship>,
findings: &mut Vec<InventoryFinding>,
) -> ContainerMountDecoded {
let bind_relabels = decode_native_bind_selinux_relabels(object, identity, findings);
let Some(value) = object.get("Mounts") else {
return ContainerMountDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
};
if value.is_null() {
return ContainerMountDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
}
let Some(mounts) = value.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Mounts",
));
return ContainerMountDecoded {
field: ObservationField::Malformed,
relationships: RelationshipDecoding {
supplied: true,
malformed: true,
},
};
};
let mut decoded = Vec::with_capacity(mounts.len());
let mut malformed = false;
for (index, mount) in mounts.iter().enumerate() {
let path = format!("$.Mounts[{index}]");
let Some(mount) = mount.as_object() else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Mounts",
index,
));
continue;
};
let Some(Value::String(kind)) = mount.get("Type") else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Mounts",
index,
));
continue;
};
let kind = match kind.as_str() {
"volume" => ContainerMountKind::NamedVolume,
"bind" => ContainerMountKind::Bind,
_ => {
malformed = true;
findings.push(InventoryFinding::field(
DiagnosticCode::NativeFieldUnsupported,
identity.clone(),
format!("{path}.Type"),
));
continue;
}
};
let destination = optional_observed_string(
mount.get("Destination"),
&format!("{path}.Destination"),
identity,
findings,
ObservationOrigin::Configured,
);
let (source, local_backing_path) = match kind {
ContainerMountKind::NamedVolume => {
let source =
match required_observed_string(mount.get("Name"), &format!("{path}.Name"), identity, findings) {
Ok(value) => ContainerMountSource::NamedVolume(value),
Err(()) => {
malformed = true;
continue;
}
};
let local = optional_observed_string(
mount.get("Source"),
&format!("{path}.Source"),
identity,
findings,
ObservationOrigin::LocalResolution,
);
(source, local)
}
ContainerMountKind::Bind => {
let source = match required_observed_string(
mount.get("Source"),
&format!("{path}.Source"),
identity,
findings,
) {
Ok(value) => ContainerMountSource::LocalBindPath(value),
Err(()) => {
malformed = true;
continue;
}
};
(source, ObservationField::Absent)
}
};
let writable = optional_observed_bool(mount.get("RW"), &format!("{path}.RW"), identity, findings);
let options =
optional_observed_string_array(mount.get("Options"), &format!("{path}.Options"), identity, findings);
let selinux_relabel = decode_mount_selinux_relabel(
mount,
&path,
&source,
&destination,
if kind == ContainerMountKind::Bind {
bind_relabels.as_deref()
} else {
Ok(&[])
},
identity,
findings,
);
let propagation = optional_observed_string(
mount.get("Propagation"),
&format!("{path}.Propagation"),
identity,
findings,
ObservationOrigin::Effective,
);
let subpath = optional_observed_string(
mount.get("SubPath"),
&format!("{path}.SubPath"),
identity,
findings,
ObservationOrigin::Configured,
);
let member_malformed = destination.is_malformed()
|| local_backing_path.is_malformed()
|| writable.is_malformed()
|| options.is_malformed()
|| selinux_relabel.is_malformed()
|| propagation.is_malformed()
|| subpath.is_malformed();
if member_malformed {
malformed = true;
continue;
}
if let (ContainerMountKind::NamedVolume, ContainerMountSource::NamedVolume(name)) = (kind, &source) {
relationships.push(NativeRelationship::new(
ResourceKind::Volume,
name,
format!("{path}.Name"),
));
}
decoded.push(ContainerMountObservation::new(
kind,
ObservationField::Observed(ObservedValue::new(
source,
match kind {
ContainerMountKind::NamedVolume => ObservationOrigin::Configured,
ContainerMountKind::Bind => ObservationOrigin::LocalResolution,
},
)),
local_backing_path,
destination,
writable,
options,
selinux_relabel,
propagation,
subpath,
));
}
ContainerMountDecoded {
field: if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(decoded, ObservationOrigin::Effective))
},
relationships: RelationshipDecoding {
supplied: true,
malformed,
},
}
}
fn decode_native_bind_selinux_relabels(
object: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> Result<Vec<NativeBindSelinuxRelabel>, ()> {
let Some(value) = object
.get("HostConfig")
.and_then(Value::as_object)
.and_then(|host_config| host_config.get("Binds"))
else {
return Ok(Vec::new());
};
if value.is_null() {
return Ok(Vec::new());
}
let Some(bindings) = value.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Binds",
));
return Err(());
};
let mut decoded: Vec<NativeBindSelinuxRelabel> = Vec::new();
let mut malformed = false;
for (index, binding) in bindings.iter().enumerate() {
let Some(binding) = binding.as_str() else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Binds",
index,
));
malformed = true;
continue;
};
let Some((mount, options)) = binding.rsplit_once(':') else {
continue;
};
let relabel = match selinux_relabel_from_tokens(options.split(',')) {
Ok(Some(relabel)) => relabel,
Ok(None) => continue,
Err(()) => {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Binds",
index,
));
malformed = true;
continue;
}
};
let Some((source, destination)) = mount.split_once(':') else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Binds",
index,
));
malformed = true;
continue;
};
if source.is_empty() || destination.is_empty() {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Binds",
index,
));
malformed = true;
continue;
}
if let Some(previous) = decoded
.iter()
.find(|candidate| candidate.source == source && candidate.destination == destination)
{
if previous.relabel != relabel {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.Binds",
index,
));
malformed = true;
}
continue;
}
decoded.push(NativeBindSelinuxRelabel {
source: source.to_owned(),
destination: destination.to_owned(),
relabel,
});
}
if malformed { Err(()) } else { Ok(decoded) }
}
fn decode_mount_selinux_relabel(
mount: &Map<String, Value>,
path: &str,
source: &ContainerMountSource,
destination: &ObservationField<String>,
bind_relabels: Result<&[NativeBindSelinuxRelabel], &()>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<ContainerMountSelinuxRelabel> {
let mode_relabel = match mount.get("Mode") {
None | Some(Value::Null) => None,
Some(Value::String(mode)) => {
let Ok(relabel) = selinux_relabel_from_tokens(mode.split(',')) else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("{path}.Mode"),
));
return ObservationField::Malformed;
};
relabel
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("{path}.Mode"),
));
return ObservationField::Malformed;
}
};
let Ok(bind_relabels) = bind_relabels else {
return ObservationField::Malformed;
};
let bind_relabel = destination
.observed()
.map(ObservedValue::value)
.and_then(|destination| {
bind_relabels
.iter()
.find(|binding| {
binding.source == source.value() && binding.destination.as_str() == destination.as_str()
})
.map(|binding| binding.relabel)
});
let relabel = match (mode_relabel, bind_relabel) {
(Some(left), Some(right)) if left != right => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("{path}.Mode"),
));
return ObservationField::Malformed;
}
(Some(relabel), _) | (_, Some(relabel)) => relabel,
(None, None) => return ObservationField::Absent,
};
ObservationField::Observed(ObservedValue::new(relabel, ObservationOrigin::Configured))
}
fn selinux_relabel_from_tokens<'a>(
tokens: impl Iterator<Item = &'a str>,
) -> Result<Option<ContainerMountSelinuxRelabel>, ()> {
let mut relabel = None;
for token in tokens {
let candidate = match token {
"z" => Some(ContainerMountSelinuxRelabel::Shared),
"Z" => Some(ContainerMountSelinuxRelabel::Private),
_ => None,
};
if let Some(candidate) = candidate {
if relabel.is_some_and(|previous| previous != candidate) {
return Err(());
}
relabel = Some(candidate);
}
}
Ok(relabel)
}
struct ContainerSecretGrantsDecoded {
field: ObservationField<Vec<ContainerSecretGrantObservation>>,
relationships: RelationshipDecoding,
}
#[allow(clippy::too_many_lines)] fn decode_container_secret_grants(
config: Option<&Value>,
identity: &ResourceIdentity,
relationships: &mut Vec<NativeRelationship>,
findings: &mut Vec<InventoryFinding>,
) -> ContainerSecretGrantsDecoded {
let Some(config) = config else {
return ContainerSecretGrantsDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
};
if config.is_null() {
return ContainerSecretGrantsDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
}
let Some(config) = config.as_object() else {
return ContainerSecretGrantsDecoded {
field: ObservationField::Malformed,
relationships: RelationshipDecoding {
supplied: true,
malformed: true,
},
};
};
let Some(secrets) = config.get("Secrets") else {
return ContainerSecretGrantsDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
};
if secrets.is_null() {
return ContainerSecretGrantsDecoded {
field: ObservationField::Absent,
relationships: RelationshipDecoding::default(),
};
}
let Some(secrets) = secrets.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config.Secrets",
));
return ContainerSecretGrantsDecoded {
field: ObservationField::Malformed,
relationships: RelationshipDecoding {
supplied: true,
malformed: true,
},
};
};
let mut decoded = Vec::with_capacity(secrets.len());
let mut malformed = false;
for (index, secret) in secrets.iter().enumerate() {
let path = format!("$.Config.Secrets[{index}]");
let Some(secret) = secret.as_object() else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config.Secrets",
index,
));
continue;
};
let id = optional_native_reference(secret.get("ID"), &format!("{path}.ID"), identity, findings);
let name = optional_native_reference(secret.get("Name"), &format!("{path}.Name"), identity, findings);
let reference = match (id, name) {
(Ok(Some(id)), Ok(Some(name))) => ObservationField::Observed(ObservedValue::new(
ContainerSecretReference::new(Some(id), Some(name)),
ObservationOrigin::Configured,
)),
(Ok(Some(id)), Ok(None)) => ObservationField::Observed(ObservedValue::new(
ContainerSecretReference::new(Some(id), None),
ObservationOrigin::Configured,
)),
(Ok(None), Ok(Some(name))) => ObservationField::Observed(ObservedValue::new(
ContainerSecretReference::new(None, Some(name)),
ObservationOrigin::Configured,
)),
(Ok(None), Ok(None)) | (Err(()), _) | (_, Err(())) => {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Config.Secrets",
index,
));
ObservationField::Malformed
}
};
let uid = optional_observed_u32(secret.get("UID"), &format!("{path}.UID"), identity, findings);
let gid = optional_observed_u32(secret.get("GID"), &format!("{path}.GID"), identity, findings);
let mode = optional_observed_u32(secret.get("Mode"), &format!("{path}.Mode"), identity, findings);
if reference.is_malformed() || uid.is_malformed() || gid.is_malformed() || mode.is_malformed() {
malformed = true;
continue;
}
if let ObservationField::Observed(reference) = &reference {
let references = reference
.value()
.id()
.into_iter()
.chain(reference.value().name())
.map(|item| (item.reference().to_owned(), item.field_path().to_owned()));
if let Some(relationship) = NativeRelationship::coalesced(ResourceKind::Secret, references) {
relationships.push(relationship);
}
}
decoded.push(ContainerSecretGrantObservation::new(reference, uid, gid, mode));
}
ContainerSecretGrantsDecoded {
field: if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(decoded, ObservationOrigin::Effective))
},
relationships: RelationshipDecoding {
supplied: true,
malformed,
},
}
}
fn decode_native_reference(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeResourceReference> {
match optional_native_reference(value, path, identity, findings) {
Ok(Some(value)) => ObservationField::Observed(ObservedValue::new(value, ObservationOrigin::Configured)),
Ok(None) => ObservationField::Absent,
Err(()) => ObservationField::Malformed,
}
}
fn optional_native_reference(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> Result<Option<NativeResourceReference>, ()> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) if !value.is_empty() => {
Ok(Some(NativeResourceReference::new(value.clone(), path.to_owned())))
}
Some(Value::String(value)) if value.is_empty() => Ok(None),
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
Err(())
}
}
}
fn decode_native_dependencies(
value: Option<&Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<NativeResourceReference>> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Array(values)) => {
let mut references = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let path = format!("$.Dependencies[{index}]");
let Ok(Some(reference)) = optional_native_reference(Some(value), &path, identity, findings) else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Dependencies",
));
return ObservationField::Malformed;
};
references.push(reference);
}
ObservationField::Observed(ObservedValue::new(references, ObservationOrigin::Configured))
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Dependencies",
));
ObservationField::Malformed
}
}
}
fn required_observed_string(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> Result<String, ()> {
match value {
Some(Value::String(value)) if !value.is_empty() => Ok(value.clone()),
_ => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
Err(())
}
}
}
fn optional_observed_string(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
origin: ObservationOrigin,
) -> ObservationField<String> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) => ObservationField::Observed(ObservedValue::new(value.clone(), origin)),
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
fn optional_observed_bool(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<bool> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Bool(value)) => {
ObservationField::Observed(ObservedValue::new(*value, ObservationOrigin::Effective))
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
#[allow(clippy::single_match_else)] fn optional_observed_string_array(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<String>> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Array(values)) => match values.iter().map(Value::as_str).collect::<Option<Vec<_>>>() {
Some(values) => ObservationField::Observed(ObservedValue::new(
values.into_iter().map(ToOwned::to_owned).collect(),
ObservationOrigin::Effective,
)),
None => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
},
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
#[allow(clippy::single_match_else)] fn optional_observed_u32(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<u32> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Number(value)) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) {
Some(value) => ObservationField::Observed(ObservedValue::new(value, ObservationOrigin::Effective)),
None => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
},
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
fn decode_pod_containers(
object: &Map<String, Value>,
identity: &ResourceIdentity,
relationships: &mut Vec<NativeRelationship>,
findings: &mut Vec<InventoryFinding>,
) -> RelationshipDecoding {
let Some(containers) = object.get("Containers") else {
return RelationshipDecoding::default();
};
if containers.is_null() {
return RelationshipDecoding::default();
}
let Some(containers) = containers.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Containers",
));
return RelationshipDecoding {
supplied: true,
malformed: true,
};
};
let mut malformed = false;
for (index, container) in containers.iter().enumerate() {
if let Some(id) = container
.as_object()
.and_then(|container| required_string(container, "Id").ok())
{
relationships.push(NativeRelationship::new(
ResourceKind::Container,
id,
format!("$.Containers[{index}].Id"),
));
} else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.Containers",
index,
));
}
}
RelationshipDecoding {
supplied: true,
malformed,
}
}
#[allow(clippy::too_many_lines)] fn decode_pod_networking(
object: &Map<String, Value>,
identity: &ResourceIdentity,
evidence: &ResourceEvidence,
relationships: &mut Vec<NativeRelationship>,
findings: &mut Vec<InventoryFinding>,
) -> (
ObservationField<bool>,
ObservationField<NativeNetworkingObservation>,
RelationshipDecoding,
) {
let create_infra = native_bool_field(
object.get("CreateInfra"),
"$.CreateInfra",
identity,
ObservationOrigin::Effective,
findings,
);
let infra_config = object.get("InfraConfig");
match (&create_infra, infra_config) {
(ObservationField::Observed(value), None | Some(Value::Null)) if *value.value() => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.InfraConfig",
));
(
create_infra,
ObservationField::Malformed,
RelationshipDecoding {
supplied: true,
malformed: true,
},
)
}
(ObservationField::Observed(value), Some(Value::Object(config))) if *value.value() => {
let (networking, relationship_decoding) = decode_native_networking(
config,
"$.InfraConfig",
identity,
evidence,
ObservationOrigin::Effective,
findings,
);
if let ObservationField::Observed(networking) = &networking {
if let ObservationField::Observed(networks) = networking.value().networks() {
relationships.extend(networks.value().iter().map(|network| {
NativeRelationship::new(ResourceKind::Network, network.reference(), network.field_path())
}));
}
}
(create_infra, networking, relationship_decoding)
}
(ObservationField::Observed(value), Some(_)) if *value.value() => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.InfraConfig",
));
(
create_infra,
ObservationField::Malformed,
RelationshipDecoding {
supplied: true,
malformed: true,
},
)
}
(ObservationField::Observed(value), Some(config)) if !*value.value() && !config.is_null() => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.InfraConfig",
));
(
create_infra,
ObservationField::Malformed,
RelationshipDecoding {
supplied: true,
malformed: true,
},
)
}
(ObservationField::Observed(value), _) if !*value.value() => {
(create_infra, ObservationField::Absent, RelationshipDecoding::default())
}
(ObservationField::Absent, None | Some(Value::Null)) => {
(create_infra, ObservationField::Absent, RelationshipDecoding::default())
}
(ObservationField::Absent, Some(config)) if !config.is_null() => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.InfraConfig",
));
(
create_infra,
ObservationField::Malformed,
RelationshipDecoding {
supplied: true,
malformed: true,
},
)
}
_ => (
create_infra,
ObservationField::Malformed,
RelationshipDecoding {
supplied: true,
malformed: true,
},
),
}
}
#[allow(clippy::too_many_lines)] fn decode_container_networking(
object: &Map<String, Value>,
pod_membership: &ObservationField<NativeResourceReference>,
networks: ObservationField<Vec<NativeResourceReference>>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeNetworkingObservation> {
if pod_membership.observed().is_some() {
if object
.get("HostConfig")
.and_then(Value::as_object)
.is_some_and(|host_config| {
[
"CreateNetNS",
"PortBindings",
"Dns",
"DnsSearch",
"DnsOptions",
"ExtraHosts",
"NoManageResolvConf",
"NoManageHosts",
]
.iter()
.any(|key| host_config.contains_key(*key))
})
{
findings.push(InventoryFinding::field(
DiagnosticCode::NativeFieldUnsupported,
identity.clone(),
"$.HostConfig",
));
}
return ObservationField::NotApplicable;
}
let host_config = match object.get("HostConfig") {
None | Some(Value::Null) => None,
Some(host_config) => {
if let Some(host_config) = host_config.as_object() {
Some(host_config)
} else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig",
));
return ObservationField::Malformed;
}
}
};
let create_net_ns = native_bool_field(
host_config.and_then(|host_config| host_config.get("CreateNetNS")),
"$.HostConfig.CreateNetNS",
identity,
ObservationOrigin::Configured,
findings,
);
let no_manage_resolv_conf = native_bool_field(
host_config.and_then(|host_config| host_config.get("NoManageResolvConf")),
"$.HostConfig.NoManageResolvConf",
identity,
ObservationOrigin::Configured,
findings,
);
let no_manage_hosts = native_bool_field(
host_config.and_then(|host_config| host_config.get("NoManageHosts")),
"$.HostConfig.NoManageHosts",
identity,
ObservationOrigin::Configured,
findings,
);
let mut local_findings = Vec::new();
let ports = native_port_bindings(
host_config.and_then(|host_config| host_config.get("PortBindings")),
"$.HostConfig.PortBindings",
identity,
ObservationOrigin::Configured,
&mut local_findings,
);
let resolver_managed = !matches!(no_manage_resolv_conf.observed().map(ObservedValue::value), Some(true));
let hosts_managed = !matches!(no_manage_hosts.observed().map(ObservedValue::value), Some(true));
let dns_servers = if resolver_managed {
native_ip_list(
host_config.and_then(|host_config| host_config.get("Dns")),
"$.HostConfig.Dns",
identity,
ObservationOrigin::Configured,
&mut local_findings,
)
} else {
ObservationField::NotApplicable
};
let dns_search = if resolver_managed {
native_string_list(
host_config.and_then(|host_config| host_config.get("DnsSearch")),
"$.HostConfig.DnsSearch",
identity,
ObservationOrigin::Configured,
&mut local_findings,
)
} else {
ObservationField::NotApplicable
};
let dns_options = if resolver_managed {
native_string_list(
host_config.and_then(|host_config| host_config.get("DnsOptions")),
"$.HostConfig.DnsOptions",
identity,
ObservationOrigin::Configured,
&mut local_findings,
)
} else {
ObservationField::NotApplicable
};
let host_entries = if hosts_managed {
native_unmodelled_string_list(
host_config.and_then(|host_config| host_config.get("ExtraHosts")),
"$.HostConfig.ExtraHosts",
identity,
crate::UnmodelledFieldId::ContainerHostConfig,
&mut local_findings,
)
} else {
ObservationField::NotApplicable
};
findings.extend(local_findings);
if ports.is_malformed()
|| dns_servers.is_malformed()
|| dns_search.is_malformed()
|| dns_options.is_malformed()
|| host_entries.is_malformed()
|| create_net_ns.is_malformed()
|| networks.is_malformed()
{
return ObservationField::Malformed;
}
if host_config.is_none() && matches!(networks, ObservationField::Absent) {
return ObservationField::Absent;
}
ObservationField::Observed(ObservedValue::new(
NativeNetworkingObservation::new(
ports,
create_net_ns,
ObservationField::NotApplicable,
dns_servers,
dns_search,
dns_options,
host_entries,
networks,
ObservationField::NotApplicable,
no_manage_resolv_conf,
no_manage_hosts,
ObservationField::NotApplicable,
ObservationField::NotApplicable,
),
ObservationOrigin::Configured,
))
}
#[allow(clippy::too_many_lines)] fn decode_native_networking(
object: &Map<String, Value>,
prefix: &str,
identity: &ResourceIdentity,
evidence: &ResourceEvidence,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> (ObservationField<NativeNetworkingObservation>, RelationshipDecoding) {
let port_bindings = native_port_bindings(
object.get("PortBindings"),
&format!("{prefix}.PortBindings"),
identity,
origin,
findings,
);
let host_network = native_bool_field(
object.get("HostNetwork"),
&format!("{prefix}.HostNetwork"),
identity,
origin,
findings,
);
let no_manage_resolv_conf = native_bool_field(
object.get("NoManageResolvConf"),
&format!("{prefix}.NoManageResolvConf"),
identity,
origin,
findings,
);
let no_manage_hosts = native_bool_field(
object.get("NoManageHosts"),
&format!("{prefix}.NoManageHosts"),
identity,
origin,
findings,
);
let resolver_managed = !matches!(no_manage_resolv_conf.observed().map(ObservedValue::value), Some(true));
let hosts_managed = !matches!(no_manage_hosts.observed().map(ObservedValue::value), Some(true));
let dns_servers = if resolver_managed {
native_ip_list(
object.get("DNSServer"),
&format!("{prefix}.DNSServer"),
identity,
origin,
findings,
)
} else {
ObservationField::NotApplicable
};
let dns_search = if resolver_managed {
native_string_list(
object.get("DNSSearch"),
&format!("{prefix}.DNSSearch"),
identity,
origin,
findings,
)
} else {
ObservationField::NotApplicable
};
let dns_options = if resolver_managed {
native_string_list(
object.get("DNSOption"),
&format!("{prefix}.DNSOption"),
identity,
origin,
findings,
)
} else {
ObservationField::NotApplicable
};
let host_entries = if hosts_managed {
native_unmodelled_string_list(
object.get("HostAdd"),
&format!("{prefix}.HostAdd"),
identity,
crate::UnmodelledFieldId::PodInfraConfig,
findings,
)
} else {
ObservationField::NotApplicable
};
let (networks, relationships) = native_network_references(
object.get("Networks"),
&format!("{prefix}.Networks"),
identity,
findings,
);
let network_options = native_opaque_options(
object.get("NetworkOptions"),
&format!("{prefix}.NetworkOptions"),
identity,
origin,
findings,
);
let static_ip = if semver::Version::parse(evidence.engine_version())
.is_ok_and(|version| version < semver::Version::new(6, 0, 0))
{
native_ip_field(
object.get("StaticIP"),
&format!("{prefix}.StaticIP"),
identity,
origin,
findings,
)
} else {
if object.get("StaticIP").is_some_and(|value| !value.is_null()) {
findings.push(InventoryFinding::field(
DiagnosticCode::VersionInapplicableField,
identity.clone(),
format!("{prefix}.StaticIP"),
));
}
ObservationField::VersionInapplicable
};
if object.get("StaticMAC").is_some_and(|value| !value.is_null()) {
findings.push(InventoryFinding::field(
DiagnosticCode::VersionInapplicableField,
identity.clone(),
format!("{prefix}.StaticMAC"),
));
}
let static_mac = ObservationField::VersionInapplicable;
let malformed = [
port_bindings.is_malformed(),
host_network.is_malformed(),
dns_servers.is_malformed(),
dns_search.is_malformed(),
dns_options.is_malformed(),
host_entries.is_malformed(),
networks.is_malformed(),
network_options.is_malformed(),
no_manage_resolv_conf.is_malformed(),
no_manage_hosts.is_malformed(),
static_ip.is_malformed(),
]
.into_iter()
.any(|value| value);
let networking = if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(
NativeNetworkingObservation::new(
port_bindings,
host_network,
ObservationField::NotApplicable,
dns_servers,
dns_search,
dns_options,
host_entries,
networks,
network_options,
no_manage_resolv_conf,
no_manage_hosts,
static_ip,
static_mac,
),
origin,
))
};
(networking, relationships)
}
fn native_bool_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<bool> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Bool(value)) => ObservationField::Observed(ObservedValue::new(*value, origin)),
Some(_) => native_malformed_field(path, identity, findings),
}
}
fn native_ip_list(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<IpAddr>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field(path, identity, findings);
};
let mut decoded = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let Some(value) = value.as_str().and_then(|value| value.parse::<IpAddr>().ok()) else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
index,
));
return ObservationField::Malformed;
};
decoded.push(value);
}
ObservationField::Observed(ObservedValue::new(decoded, origin))
}
fn native_string_list(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<String>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field(path, identity, findings);
};
let mut decoded = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let Some(value) = value
.as_str()
.filter(|value| !value.is_empty() && !value.chars().any(char::is_control))
else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
index,
));
return ObservationField::Malformed;
};
decoded.push(value.to_owned());
}
ObservationField::Observed(ObservedValue::new(decoded, origin))
}
fn native_unmodelled_string_list(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
field: crate::UnmodelledFieldId,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeOpaqueNetworkOptions> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
return native_malformed_field(path, identity, findings);
};
if values.iter().any(|value| !value.is_string()) {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
return ObservationField::Malformed;
}
ObservationField::Unmodelled(field)
}
fn native_opaque_options(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeOpaqueNetworkOptions> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(options) = value.as_object() else {
return native_malformed_field(path, identity, findings);
};
ObservationField::Observed(ObservedValue::new(
NativeOpaqueNetworkOptions::new(options.len()),
origin,
))
}
fn native_network_references(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> (ObservationField<Vec<NativeResourceReference>>, RelationshipDecoding) {
let Some(value) = value else {
return (ObservationField::Absent, RelationshipDecoding::default());
};
if value.is_null() {
return (ObservationField::Absent, RelationshipDecoding::default());
}
let Some(values) = value.as_array() else {
let field = native_malformed_field(path, identity, findings);
return (
field,
RelationshipDecoding {
supplied: true,
malformed: true,
},
);
};
let mut references = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let Some(value) = value.as_str().filter(|value| !value.is_empty()) else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
index,
));
return (
ObservationField::Malformed,
RelationshipDecoding {
supplied: true,
malformed: true,
},
);
};
references.push(NativeResourceReference::new(
value.to_owned(),
format!("{path}[{index}]"),
));
}
(
ObservationField::Observed(ObservedValue::new(references, ObservationOrigin::Effective)),
RelationshipDecoding {
supplied: true,
malformed: false,
},
)
}
fn native_port_bindings(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<NativePortBindingObservation>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(bindings) = value.as_object() else {
return native_malformed_field(path, identity, findings);
};
let mut decoded = Vec::new();
for (key, values) in bindings {
let Some((port, protocol)) = native_port_binding_key(key) else {
return native_malformed_field(&format!("{path}.{key}"), identity, findings);
};
let Some(values) = values.as_array() else {
return native_malformed_field(&format!("{path}.{key}"), identity, findings);
};
for (index, value) in values.iter().enumerate() {
let Some(value) = value.as_object() else {
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
format!("{path}.{key}"),
index,
));
return ObservationField::Malformed;
};
let host_ip = match value.get("HostIp") {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) if value.is_empty() => ObservationField::Absent,
Some(Value::String(value)) => match value.parse() {
Ok(value) => ObservationField::Observed(ObservedValue::new(value, origin)),
Err(_) => {
return native_malformed_field(&format!("{path}.{key}[{index}].HostIp"), identity, findings);
}
},
Some(_) => return native_malformed_field(&format!("{path}.{key}[{index}].HostIp"), identity, findings),
};
let host_port = match value.get("HostPort") {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) if value.is_empty() => ObservationField::Absent,
Some(Value::String(value)) => match value.parse() {
Ok(value) if value != 0 => ObservationField::Observed(ObservedValue::new(value, origin)),
_ => return native_malformed_field(&format!("{path}.{key}[{index}].HostPort"), identity, findings),
},
Some(_) => {
return native_malformed_field(&format!("{path}.{key}[{index}].HostPort"), identity, findings);
}
};
decoded.push(NativePortBindingObservation::new(port, protocol, host_ip, host_port));
}
}
ObservationField::Observed(ObservedValue::new(decoded, origin))
}
fn native_port_binding_key(value: &str) -> Option<(u16, NativePortProtocol)> {
let (port, protocol) = value.split_once('/')?;
let port = port.parse().ok().filter(|port: &u16| *port != 0)?;
let protocol = match protocol {
"tcp" => NativePortProtocol::Tcp,
"udp" => NativePortProtocol::Udp,
"sctp" => NativePortProtocol::Sctp,
_ => return None,
};
Some((port, protocol))
}
fn decode_memory_swappiness(
object: &Map<String, Value>,
evidence: &ResourceEvidence,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<u64> {
let Some(host_config) = object.get("HostConfig") else {
return ObservationField::Absent;
};
if host_config.is_null() {
return ObservationField::Absent;
}
let Some(host_config) = host_config.as_object() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig",
));
return ObservationField::Malformed;
};
let Some(value) = host_config.get("MemorySwappiness") else {
return ObservationField::Absent;
};
if value.is_null() {
if evidence.api_version().starts_with("5.4.") {
findings.push(InventoryFinding::field(
DiagnosticCode::VersionInapplicableField,
identity.clone(),
"$.HostConfig.MemorySwappiness",
));
return ObservationField::VersionInapplicable;
}
return ObservationField::Absent;
}
match value.as_i64() {
Some(-1) => ObservationField::Absent,
Some(value @ 0..=100) => {
ObservationField::Observed(ObservedValue::new(value.unsigned_abs(), ObservationOrigin::Effective))
}
_ => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.HostConfig.MemorySwappiness",
));
ObservationField::Malformed
}
}
}
fn decode_is_infra(
object: &Map<String, Value>,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<bool> {
match object.get("IsInfra") {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::Bool(value)) => {
ObservationField::Observed(ObservedValue::new(*value, ObservationOrigin::Effective))
}
Some(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
"$.IsInfra",
));
ObservationField::Malformed
}
}
}
fn image_aliases(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Vec<String>> {
let Some(value) = value else {
return ObservationField::Absent;
};
if value.is_null() {
return ObservationField::Absent;
}
let Some(values) = value.as_array() else {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
return ObservationField::Malformed;
};
let mut aliases = Vec::with_capacity(values.len());
let mut malformed = false;
for (index, value) in values.iter().enumerate() {
if let Some(value) = value.as_str().filter(|value| !value.is_empty()) {
aliases.push(value.to_owned());
} else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
index,
));
}
}
if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(aliases, ObservationOrigin::LocalResolution))
}
}
fn optional_string_or_empty_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
origin: ObservationOrigin,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<String> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) if value.is_empty() => ObservationField::Absent,
Some(Value::String(value)) => ObservationField::Observed(ObservedValue::new(value.clone(), origin)),
Some(_) => native_malformed_field(path, identity, findings),
}
}
fn native_timestamp_field(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<NativeTimestamp> {
match value {
None | Some(Value::Null) => ObservationField::Absent,
Some(Value::String(value)) if native_timestamp_is_rfc3339(value) => ObservationField::Observed(
ObservedValue::new(NativeTimestamp::new(value.clone()), ObservationOrigin::Effective),
),
Some(_) => native_malformed_field(path, identity, findings),
}
}
fn native_timestamp_is_rfc3339(value: &str) -> bool {
let bytes = value.as_bytes();
if !(20..=35).contains(&bytes.len())
|| bytes.get(4) != Some(&b'-')
|| bytes.get(7) != Some(&b'-')
|| bytes.get(10) != Some(&b'T')
|| bytes.get(13) != Some(&b':')
|| bytes.get(16) != Some(&b':')
{
return false;
}
let Some(year) = timestamp_digits(bytes, 0, 4) else {
return false;
};
let Some(month) = timestamp_digits(bytes, 5, 2) else {
return false;
};
let Some(day) = timestamp_digits(bytes, 8, 2) else {
return false;
};
let Some(hour) = timestamp_digits(bytes, 11, 2) else {
return false;
};
let Some(minute) = timestamp_digits(bytes, 14, 2) else {
return false;
};
let Some(second) = timestamp_digits(bytes, 17, 2) else {
return false;
};
if !(1..=12).contains(&month)
|| day == 0
|| day > timestamp_days_in_month(year, month)
|| hour > 23
|| minute > 59
|| second > 60
{
return false;
}
let mut suffix = 19;
if bytes.get(suffix) == Some(&b'.') {
suffix += 1;
let fraction_start = suffix;
while bytes.get(suffix).is_some_and(u8::is_ascii_digit) {
suffix += 1;
}
if suffix == fraction_start || suffix - fraction_start > 9 {
return false;
}
}
match bytes.get(suffix) {
Some(b'Z') => suffix + 1 == bytes.len(),
Some(b'+' | b'-') => {
suffix + 6 == bytes.len()
&& bytes.get(suffix + 3) == Some(&b':')
&& timestamp_digits(bytes, suffix + 1, 2).is_some_and(|hour| hour <= 23)
&& timestamp_digits(bytes, suffix + 4, 2).is_some_and(|minute| minute <= 59)
}
_ => false,
}
}
fn timestamp_digits(bytes: &[u8], start: usize, len: usize) -> Option<u32> {
let digits = bytes.get(start..start + len)?;
digits.iter().try_fold(0_u32, |value, digit| {
digit.is_ascii_digit().then_some(value * 10 + u32::from(*digit - b'0'))
})
}
const fn timestamp_days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
2 => 28,
_ => 0,
}
}
fn relationship_field(
relationships: Vec<NativeRelationship>,
decoding: RelationshipDecoding,
) -> ObservationField<Vec<NativeRelationship>> {
if decoding.malformed {
ObservationField::Malformed
} else if !decoding.supplied {
ObservationField::Absent
} else {
ObservationField::Observed(ObservedValue::new(relationships, ObservationOrigin::Effective))
}
}
fn decode_environment(
value: Option<&Value>,
policy: EnvironmentValuePolicy,
identity: &ResourceIdentity,
path: &str,
) -> (ObservationField<ProtectedEnvironment>, Vec<InventoryFinding>) {
let Some(value) = value else {
return (ObservationField::Absent, Vec::new());
};
if value.is_null() {
return (ObservationField::Absent, Vec::new());
}
let Some(entries) = value.as_array() else {
return (
ObservationField::Malformed,
vec![InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
)],
);
};
let mut decoded = Vec::with_capacity(entries.len());
let mut findings = Vec::new();
let mut malformed = false;
for (index, entry) in entries.iter().enumerate() {
let Some(entry) = entry.as_str() else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::EnvironmentMalformed,
identity.clone(),
path,
index,
));
continue;
};
let Some((name, value)) = entry.split_once('=') else {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::EnvironmentMalformed,
identity.clone(),
path,
index,
));
continue;
};
if name.is_empty() {
malformed = true;
findings.push(InventoryFinding::at_occurrence(
DiagnosticCode::EnvironmentMalformed,
identity.clone(),
path,
index,
));
continue;
}
decoded.push(ProtectedEnvironmentEntry::new(
name.to_owned(),
match policy {
EnvironmentValuePolicy::Redact => ProtectedEnvironmentValue::Redacted,
EnvironmentValuePolicy::Include => {
ProtectedEnvironmentValue::AuthorizedOpaque(SensitiveEnvironmentValue::new(value.to_owned()))
}
},
));
}
let field = if malformed {
ObservationField::Malformed
} else {
ObservationField::Observed(ObservedValue::new(
ProtectedEnvironment::new(decoded),
ObservationOrigin::Effective,
))
};
(field, findings)
}
fn labels(
value: Option<&Value>,
path: &str,
identity: &ResourceIdentity,
findings: &mut Vec<InventoryFinding>,
) -> ObservationField<Labels> {
match string_map(value) {
Ok(Some(labels)) => ObservationField::Observed(ObservedValue::new(labels, ObservationOrigin::Configured)),
Ok(None) => ObservationField::Absent,
Err(_) => {
findings.push(InventoryFinding::field(
DiagnosticCode::ResourceMalformed,
identity.clone(),
path,
));
ObservationField::Malformed
}
}
}
fn string_map(value: Option<&Value>) -> PodmanLensResult<Option<BTreeMap<String, String>>> {
let Some(value) = value else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let object = value
.as_object()
.ok_or_else(|| Diagnostic::new(DiagnosticCode::ResourceMalformed))?;
object
.iter()
.map(|(key, value)| {
value
.as_str()
.map(|value| (key.clone(), value.to_owned()))
.ok_or_else(|| Diagnostic::new(DiagnosticCode::ResourceMalformed))
})
.collect::<PodmanLensResult<BTreeMap<_, _>>>()
.map(Some)
}
struct UnknownFieldCollector<'a> {
resource: &'a ResourceIdentity,
evidence: &'a ResourceEvidence,
limit: usize,
fields: Vec<UnmodelledField>,
overflowed: bool,
}
impl<'a> UnknownFieldCollector<'a> {
fn new(resource: &'a ResourceIdentity, evidence: &'a ResourceEvidence, limit: usize) -> Self {
Self {
resource,
evidence,
limit,
fields: Vec::new(),
overflowed: false,
}
}
fn push(&mut self, path: impl FnOnce() -> String, value: &Value) -> bool {
if self.fields.len() >= self.limit {
self.overflowed = true;
return false;
}
self.fields.push(UnmodelledField::new(
path(),
json_value_kind(value),
self.resource.clone(),
self.evidence.clone(),
));
true
}
fn push_kind(&mut self, path: String, kind: JsonValueKind) -> bool {
if self.fields.len() >= self.limit {
self.overflowed = true;
return false;
}
self.fields.push(UnmodelledField::new(
path,
kind,
self.resource.clone(),
self.evidence.clone(),
));
true
}
fn finish(self) -> (Vec<UnmodelledField>, bool) {
(self.fields, self.overflowed)
}
}
fn unknown_top_level(
kind: ResourceKind,
object: &Map<String, Value>,
known: &[&str],
fields: &mut UnknownFieldCollector<'_>,
) {
for (key, value) in object
.iter()
.filter(|(key, _)| !known.contains(&key.as_str()) && !is_known_runtime_only_field(kind, key))
{
if !fields.push(|| format!("$.{key}"), value) {
break;
}
}
}
fn is_known_runtime_only_field(kind: ResourceKind, key: &str) -> bool {
match kind {
ResourceKind::Container => CONTAINER_RUNTIME_ONLY_TOP_LEVEL_FIELDS.contains(&key),
ResourceKind::Network => NETWORK_RUNTIME_ONLY_TOP_LEVEL_FIELDS.contains(&key),
ResourceKind::Volume => VOLUME_RUNTIME_ONLY_TOP_LEVEL_FIELDS.contains(&key),
ResourceKind::Image => IMAGE_RUNTIME_ONLY_TOP_LEVEL_FIELDS.contains(&key),
ResourceKind::Pod | ResourceKind::Secret => false,
}
}
#[allow(clippy::too_many_lines)] fn unknown_nested_fields(
kind: ResourceKind,
object: &Map<String, Value>,
evidence: &ResourceEvidence,
fields: &mut UnknownFieldCollector<'_>,
) {
match kind {
ResourceKind::Container => {
unknown_object_members(
object.get("Config"),
"$.Config",
&[
"Labels",
"Env",
"Secrets",
"Cmd",
"Entrypoint",
"User",
"WorkingDir",
"CreateCommand",
"Hostname",
"Healthcheck",
"HealthcheckOnFailureAction",
"StartupHealthCheck",
],
fields,
);
unknown_object_members(
object
.get("Config")
.and_then(Value::as_object)
.and_then(|config| config.get("Healthcheck")),
"$.Config.Healthcheck",
&["Test", "Interval", "Timeout", "Retries", "StartPeriod"],
fields,
);
unknown_object_members(
object
.get("Config")
.and_then(Value::as_object)
.and_then(|config| config.get("StartupHealthCheck")),
"$.Config.StartupHealthCheck",
&["Test", "Interval", "Timeout", "Retries", "StartPeriod", "Successes"],
fields,
);
unknown_object_members(
object.get("NetworkSettings"),
"$.NetworkSettings",
&["Networks"],
fields,
);
if let Some(networks) = object
.get("NetworkSettings")
.and_then(Value::as_object)
.and_then(|settings| settings.get("Networks"))
.and_then(Value::as_object)
{
for (name, details) in networks {
if details.as_object().is_some_and(|members| !members.is_empty())
&& !fields.push(|| format!("$.NetworkSettings.Networks.{name}"), details)
{
break;
}
if fields.overflowed {
break;
}
}
}
unknown_array_object_members(
object.get("Mounts"),
"$.Mounts",
&[
"Type",
"Name",
"Source",
"Destination",
"RW",
"Options",
"Mode",
"Propagation",
"SubPath",
],
fields,
);
unknown_unrepresented_mount_modes(object.get("Mounts"), fields);
unknown_unsupported_mounts(object.get("Mounts"), fields);
unknown_array_object_members(
object
.get("Config")
.and_then(Value::as_object)
.and_then(|config| config.get("Secrets")),
"$.Config.Secrets",
&["ID", "Name", "UID", "GID", "Mode"],
fields,
);
unknown_object_members(
object
.get("HostConfig")
.and_then(Value::as_object)
.and_then(|host_config| host_config.get("RestartPolicy")),
"$.HostConfig.RestartPolicy",
&["Name", "MaximumRetryCount"],
fields,
);
unknown_object_members(
object
.get("HostConfig")
.and_then(Value::as_object)
.and_then(|host_config| host_config.get("LogConfig")),
"$.HostConfig.LogConfig",
&["Type", "Size"],
fields,
);
unknown_array_object_members(
object
.get("HostConfig")
.and_then(Value::as_object)
.and_then(|host_config| host_config.get("Ulimits")),
"$.HostConfig.Ulimits",
&["Name", "Soft", "Hard"],
fields,
);
unknown_object_members(
object.get("HostConfig"),
"$.HostConfig",
&[
"MemorySwappiness",
"CreateNetNS",
"PortBindings",
"Dns",
"DnsSearch",
"DnsOptions",
"NoManageResolvConf",
"NoManageHosts",
"RestartPolicy",
"LogConfig",
"Privileged",
"CapAdd",
"CapDrop",
"SecurityOpt",
"ReadonlyRootfs",
"PidMode",
"IpcMode",
"UTSMode",
"CgroupMode",
"CpuShares",
"CpuPeriod",
"CpuQuota",
"Memory",
"PidsLimit",
"Ulimits",
],
fields,
);
}
ResourceKind::Pod => {
unknown_array_object_members(object.get("Containers"), "$.Containers", &["Id"], fields);
unknown_object_members(
object.get("InfraConfig"),
"$.InfraConfig",
&[
"PortBindings",
"HostNetwork",
"DNSServer",
"DNSSearch",
"DNSOption",
"Networks",
"NetworkOptions",
"NoManageResolvConf",
"NoManageHosts",
"StaticMAC",
"StaticIP",
],
fields,
);
}
ResourceKind::Network => unknown_network_nested_fields(object, evidence, fields),
ResourceKind::Image => unknown_object_members(object.get("Config"), "$.Config", &["Env"], fields),
ResourceKind::Secret => {
unknown_object_members(
object.get("Spec"),
"$.Spec",
&["Name", "Labels", "Driver", "SecretData"],
fields,
);
unknown_object_members(
object
.get("Spec")
.and_then(Value::as_object)
.and_then(|spec| spec.get("Driver")),
"$.Spec.Driver",
&["Name", "Options"],
fields,
);
}
ResourceKind::Volume => {}
}
}
fn unknown_network_nested_fields(
object: &Map<String, Value>,
evidence: &ResourceEvidence,
fields: &mut UnknownFieldCollector<'_>,
) {
unknown_array_object_members(
object.get("subnets"),
"$.subnets",
&["subnet", "gateway", "lease_range"],
fields,
);
if let Some(subnets) = object.get("subnets").and_then(Value::as_array) {
for (index, subnet) in subnets.iter().enumerate() {
unknown_object_members(
subnet.as_object().and_then(|subnet| subnet.get("lease_range")),
&format!("$.subnets[{index}].lease_range"),
&["start_ip", "end_ip"],
fields,
);
}
}
unknown_array_object_members(
object.get("routes"),
"$.routes",
&["destination", "gateway", "metric", "route_type"],
fields,
);
unknown_network_route_type_members(object.get("routes"), evidence, fields);
}
fn unknown_object_members(value: Option<&Value>, path: &str, known: &[&str], fields: &mut UnknownFieldCollector<'_>) {
let Some(object) = value.and_then(Value::as_object) else {
return;
};
for (key, value) in object.iter().filter(|(key, _)| !known.contains(&key.as_str())) {
if !fields.push(|| format!("{path}.{key}"), value) {
break;
}
}
}
fn unknown_array_object_members(
value: Option<&Value>,
path: &str,
known: &[&str],
fields: &mut UnknownFieldCollector<'_>,
) {
let Some(values) = value.and_then(Value::as_array) else {
return;
};
for (index, value) in values.iter().enumerate() {
unknown_object_members(Some(value), &format!("{path}[{index}]"), known, fields);
if fields.overflowed {
break;
}
}
}
fn unknown_unsupported_mounts(value: Option<&Value>, fields: &mut UnknownFieldCollector<'_>) {
let Some(mounts) = value.and_then(Value::as_array) else {
return;
};
for (index, mount) in mounts.iter().enumerate() {
let unsupported = mount
.as_object()
.and_then(|mount| mount.get("Type"))
.and_then(Value::as_str)
.is_some_and(|kind| !matches!(kind, "volume" | "bind"));
if unsupported && !fields.push(|| format!("$.Mounts[{index}]"), mount) {
break;
}
}
}
fn unknown_unrepresented_mount_modes(value: Option<&Value>, fields: &mut UnknownFieldCollector<'_>) {
let Some(mounts) = value.and_then(Value::as_array) else {
return;
};
for (index, mount) in mounts.iter().enumerate() {
let Some(mount) = mount.as_object() else {
continue;
};
let Some(mode) = mount.get("Mode").and_then(Value::as_str) else {
continue;
};
let represented = mode
.split(',')
.filter(|token| !token.is_empty())
.all(|token| match token {
"z" | "Z" => true,
"ro" => mount.get("RW").and_then(Value::as_bool) == Some(false),
"rw" => mount.get("RW").and_then(Value::as_bool) == Some(true),
value if mount.get("Propagation").and_then(Value::as_str) == Some(value) => true,
value => mount
.get("Options")
.and_then(Value::as_array)
.is_some_and(|options| options.iter().any(|option| option.as_str() == Some(value))),
});
if !represented && !fields.push_kind(format!("$.Mounts[{index}].Mode"), JsonValueKind::String) {
break;
}
}
}
fn unknown_network_route_type_members(
value: Option<&Value>,
evidence: &ResourceEvidence,
fields: &mut UnknownFieldCollector<'_>,
) {
let Some(routes) = value.and_then(Value::as_array) else {
return;
};
for (index, route) in routes.iter().enumerate() {
let Some(route) = route.as_object() else {
continue;
};
let Some(route_type) = route.get("route_type") else {
continue;
};
let unsupported = !native_network_route_types_are_available(evidence)
|| !route_type
.as_str()
.is_some_and(|value| matches!(value, "unicast" | "blackhole" | "unreachable" | "prohibit"));
if unsupported && !fields.push(|| format!("$.routes[{index}].route_type"), route_type) {
break;
}
}
}
fn require_ok_json(response: &LibpodResponse) -> PodmanLensResult<()> {
if response.status() != 200 {
return Err(Diagnostic::new(DiagnosticCode::InventoryHttpStatus));
}
let values = response.headers().values("content-type").collect::<Vec<_>>();
let [value] = values.as_slice() else {
return Err(Diagnostic::new(DiagnosticCode::InventoryShape));
};
if value
.split(';')
.next()
.is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case("application/json"))
{
Ok(())
} else {
Err(Diagnostic::new(DiagnosticCode::InventoryShape))
}
}
fn decode_json(body: &[u8]) -> PodmanLensResult<Value> {
if body.len() > MAX_INVENTORY_JSON_BYTES {
return Err(Diagnostic::new(DiagnosticCode::InventoryJson));
}
serde_json::from_slice(body).map_err(|_| Diagnostic::new(DiagnosticCode::InventoryJson))
}
fn required_string<'a>(object: &'a Map<String, Value>, key: &str) -> PodmanLensResult<&'a str> {
object
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| Diagnostic::new(DiagnosticCode::InventoryShape))
}
fn required_string_any<'a>(object: &'a Map<String, Value>, keys: &[&str]) -> PodmanLensResult<&'a str> {
keys.iter()
.find_map(|key| {
object
.get(*key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.ok_or_else(|| Diagnostic::new(DiagnosticCode::InventoryShape))
}
fn optional_string<'a>(object: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
object
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
}
fn optional_string_any<'a>(object: &'a Map<String, Value>, keys: &[&str]) -> Option<&'a str> {
keys.iter().find_map(|key| optional_string(object, key))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::items_after_test_module, clippy::panic)]
mod typed_observation_constructor_tests {
use super::*;
use crate::capability_catalogue;
fn header(kind: ResourceKind) -> ObservationHeader {
let capability = capability_catalogue().expect("embedded capability catalogue").remove(0);
ObservationHeader::complete(
ResourceIdentity::new(kind, format!("{kind:?}-id"), None),
ResourceEvidence {
engine_version: "6.1.0".to_owned(),
api_version: "6.1.0".to_owned(),
capability,
},
Vec::new(),
Vec::new(),
UnmodelledCompleteness::Complete,
)
}
fn typed_bind_mount(selinux_relabel: ObservationField<ContainerMountSelinuxRelabel>) -> ContainerMountObservation {
ContainerMountObservation::new(
ContainerMountKind::Bind,
ObservationField::Observed(ObservedValue::new(
ContainerMountSource::LocalBindPath("/source".to_owned()),
ObservationOrigin::LocalResolution,
)),
ObservationField::Absent,
ObservationField::Observed(ObservedValue::new(
"/destination".to_owned(),
ObservationOrigin::Configured,
)),
ObservationField::Absent,
ObservationField::Absent,
selinux_relabel,
ObservationField::Absent,
ObservationField::Absent,
)
}
#[test]
fn list_paths_use_the_normalized_protocol_version() {
let api = ObservedApiVersion::parse_reported("4.9.4-rhel").expect("reviewed RHEL alias");
let path = list_path(&api, ResourceKind::Container).expect("container list path");
assert_eq!(path.as_str(), "/v4.9.4/libpod/containers/json?all=true&sync=true");
assert_eq!(api.original(), "4.9.4-rhel");
}
#[test]
fn creation_command_parser_stops_at_image_and_rejects_unknown_option_arity() {
let command = serde_json::json!([
"podman",
"run",
"--env",
"SENTINEL_ENV=SENTINEL_VALUE",
"--volume",
"/SENTINEL_SOURCE:/container:Z",
"example.invalid/canary:1",
"SENTINEL_POST_IMAGE_COMMAND"
]);
let CreateCommandParse::Parsed(parsed) = parse_create_command(Some(&command)) else {
panic!("bounded known command must parse");
};
assert_eq!(parsed.image, "example.invalid/canary:1");
let ParsedCreateMountRelabels::Observed(mounts) = parsed.mount_relabels else {
panic!("reviewed relabel must be observed");
};
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].relabel, ContainerMountSelinuxRelabel::Private);
assert!(matches!(
parse_create_command(Some(&serde_json::json!([
"podman",
"run",
"--future-option",
"value",
"image"
]))),
CreateCommandParse::Unavailable
));
}
#[test]
fn malformed_or_ambiguous_relabel_mounts_preserve_independent_image_evidence() {
let identity = ResourceIdentity::new(
ResourceKind::Container,
"container-id".to_owned(),
Some("canary".to_owned()),
);
let configured_image = ObservationField::Observed(ObservedValue::new(
"example.invalid/canary:1".to_owned(),
ObservationOrigin::Configured,
));
let ambiguous_volume = serde_json::json!([
"podman",
"run",
"--volume",
"/SENTINEL_VOLUME_SOURCE:/data:z,Z",
"example.invalid/canary:1"
]);
let mut findings = Vec::new();
let result = decode_container_creation_evidence(
Some(&ambiguous_volume),
&configured_image,
&ObservationField::Absent,
&ObservationField::Absent,
&identity,
&mut findings,
);
let ObservationField::Observed(result) = result else {
panic!("ambiguous relabel must not erase independent creation evidence");
};
assert!(matches!(
result.value().image(),
ObservationField::Observed(image)
if *image.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(result.value().mount_relabels(), ObservationField::Unavailable));
assert!(findings.is_empty());
let malformed_mount = serde_json::json!([
"podman",
"run",
"--mount",
"type=bind,src=/SENTINEL_MOUNT_SOURCE,target=/data,relabel",
"example.invalid/canary:1"
]);
let mut findings = Vec::new();
let result = decode_container_creation_evidence(
Some(&malformed_mount),
&configured_image,
&ObservationField::Absent,
&ObservationField::Absent,
&identity,
&mut findings,
);
let ObservationField::Observed(result) = result else {
panic!("malformed mount must not erase independent creation evidence");
};
assert!(matches!(
result.value().image(),
ObservationField::Observed(image)
if *image.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(result.value().mount_relabels(), ObservationField::Malformed));
assert!(findings.iter().any(|finding| {
finding.code() == DiagnosticCode::ResourceMalformed
&& finding.field_path() == Some("$.Config.CreateCommand")
}));
let finding_debug = format!("{findings:?}");
assert!(!finding_debug.contains("SENTINEL_MOUNT_SOURCE"));
}
#[test]
fn typed_mount_relabel_state_only_conflicts_when_observed_and_different() {
let identity = ResourceIdentity::new(
ResourceKind::Container,
"container-id".to_owned(),
Some("canary".to_owned()),
);
let configured_image = ObservationField::Observed(ObservedValue::new(
"example.invalid/canary:1".to_owned(),
ObservationOrigin::Configured,
));
let command = serde_json::json!([
"podman",
"run",
"--volume",
"/source:/destination:z",
"example.invalid/canary:1"
]);
for relabel_state in [
ObservationField::Absent,
ObservationField::Unavailable,
ObservationField::Malformed,
] {
let mounts = ObservationField::Observed(ObservedValue::new(
vec![typed_bind_mount(relabel_state)],
ObservationOrigin::Effective,
));
let mut findings = Vec::new();
let evidence = decode_container_creation_evidence(
Some(&command),
&configured_image,
&ObservationField::Absent,
&mounts,
&identity,
&mut findings,
);
let ObservationField::Observed(evidence) = evidence else {
panic!("incomplete relabel state must preserve independent image evidence");
};
assert!(matches!(
evidence.value().image(),
ObservationField::Observed(image)
if *image.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(
evidence.value().mount_relabels(),
ObservationField::Unavailable
));
assert!(findings.is_empty(), "incomplete evidence is not contradictory");
}
let mounts = ObservationField::Observed(ObservedValue::new(
vec![typed_bind_mount(ObservationField::Observed(ObservedValue::new(
ContainerMountSelinuxRelabel::Private,
ObservationOrigin::Configured,
)))],
ObservationOrigin::Effective,
));
let mut findings = Vec::new();
let evidence = decode_container_creation_evidence(
Some(&command),
&configured_image,
&ObservationField::Absent,
&mounts,
&identity,
&mut findings,
);
let ObservationField::Observed(evidence) = evidence else {
panic!("observed disagreement must retain independent creation evidence");
};
assert!(matches!(
evidence.value().mount_relabels(),
ObservationField::Observed(relabels)
if relabels.value().as_slice()
== [AuthoredMountRelabelHint::Contradictory { mount_index: 0 }]
));
assert!(findings.iter().any(|finding| {
finding.code() == DiagnosticCode::CreationEvidenceConflict
&& finding.field_path() == Some("$.Config.CreateCommand")
&& finding.occurrence() == Some(0)
}));
}
#[test]
fn unknown_structured_mount_option_is_unavailable_without_erasing_image_hint() {
let identity = ResourceIdentity::new(
ResourceKind::Container,
"container-id".to_owned(),
Some("canary".to_owned()),
);
let configured_image = ObservationField::Observed(ObservedValue::new(
"example.invalid/canary:1".to_owned(),
ObservationOrigin::Configured,
));
let command = serde_json::json!([
"podman",
"run",
"--mount",
"type=bind,src=/SENTINEL_SOURCE,target=/data,relabel=private,future-option=value",
"example.invalid/canary:1"
]);
let mut findings = Vec::new();
let evidence = decode_container_creation_evidence(
Some(&command),
&configured_image,
&ObservationField::Absent,
&ObservationField::Absent,
&identity,
&mut findings,
);
let ObservationField::Observed(evidence) = evidence else {
panic!("unknown mount option must preserve independent creation evidence");
};
assert!(matches!(
evidence.value().image(),
ObservationField::Observed(image)
if *image.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(
evidence.value().mount_relabels(),
ObservationField::Unavailable
));
assert!(findings.is_empty());
assert!(!format!("{evidence:?}").contains("SENTINEL_SOURCE"));
}
#[test]
fn captured_cli_shape_accepts_known_equals_options_without_retaining_them() {
let command = serde_json::json!([
"podman",
"create",
"--pull=never",
"--name",
"capture-name",
"--network=none",
"--volume",
"/capture-only/data:/data:Z",
"localhost/pl27-authored:1",
"capture-post-image-command"
]);
let CreateCommandParse::Parsed(parsed) = parse_create_command(Some(&command)) else {
panic!("reviewed sanitized CLI shape must parse");
};
assert_eq!(parsed.image, "localhost/pl27-authored:1");
let ParsedCreateMountRelabels::Observed(mounts) = parsed.mount_relabels else {
panic!("reviewed relabel must be observed");
};
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].relabel, ContainerMountSelinuxRelabel::Private);
}
#[test]
#[allow(clippy::too_many_lines)] fn derived_native_cli_and_provider_fixture_preserves_authored_vs_absent_evidence() {
let fixture: Value = serde_json::from_str(include_str!(
"../fixtures/native-regressions/creation-evidence-6.1.0.json"
))
.expect("reviewed sanitized fixture");
let identity = ResourceIdentity::new(
ResourceKind::Container,
"fixture-container".to_owned(),
Some("fixture".to_owned()),
);
let cli = fixture["cli_authored"].as_object().expect("CLI-authored object");
let mut findings = Vec::new();
let cli_mounts = decode_container_mounts(cli, &identity, &mut Vec::new(), &mut findings);
let configured_image = ObservationField::Observed(ObservedValue::new(
cli["ImageName"].as_str().expect("image name").to_owned(),
ObservationOrigin::Configured,
));
let local_image_id = ObservationField::Observed(ObservedValue::new(
cli["Image"].as_str().expect("image").to_owned(),
ObservationOrigin::LocalResolution,
));
let cli_evidence = decode_container_creation_evidence(
container_create_command(cli),
&configured_image,
&local_image_id,
&cli_mounts.field,
&identity,
&mut findings,
);
let cli_evidence = cli_evidence.observed().expect("CLI creation evidence").value();
assert!(matches!(
cli_evidence.image(),
ObservationField::Observed(value)
if *value.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(
cli_evidence.mount_relabels(),
ObservationField::Observed(value)
if value.value().as_slice() == [AuthoredMountRelabelHint::Private { mount_index: 0 }]
));
for unavailable_configured in [
ObservationField::Absent,
ObservationField::Malformed,
ObservationField::Unavailable,
] {
let independent = decode_container_creation_evidence(
container_create_command(cli),
&unavailable_configured,
&ObservationField::Observed(ObservedValue::new(
"sha256:ordinary-local-id".to_owned(),
ObservationOrigin::LocalResolution,
)),
&cli_mounts.field,
&identity,
&mut findings,
);
let independent = independent.observed().expect("independent creation evidence").value();
assert!(matches!(independent.image(), ObservationField::Unavailable));
assert!(matches!(
independent.mount_relabels(),
ObservationField::Observed(value)
if value.value().as_slice() == [AuthoredMountRelabelHint::Private { mount_index: 0 }]
));
}
let image_without_mounts = decode_container_creation_evidence(
container_create_command(cli),
&configured_image,
&local_image_id,
&ObservationField::Unavailable,
&identity,
&mut findings,
);
let image_without_mounts = image_without_mounts
.observed()
.expect("independent creation evidence")
.value();
assert!(matches!(
image_without_mounts.image(),
ObservationField::Observed(value)
if *value.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(
image_without_mounts.mount_relabels(),
ObservationField::Unavailable
));
let provider = fixture["provider"].as_object().expect("provider object");
let tagged = fixture["cli_tagged"].as_object().expect("CLI-tagged object");
assert_eq!(cli["Image"], tagged["Image"]);
assert_eq!(tagged["Image"], provider["Image"]);
let tagged_mounts = decode_container_mounts(tagged, &identity, &mut Vec::new(), &mut findings);
let tagged_evidence = decode_container_creation_evidence(
container_create_command(tagged),
&ObservationField::Observed(ObservedValue::new(
tagged["ImageName"].as_str().expect("tagged image name").to_owned(),
ObservationOrigin::Configured,
)),
&ObservationField::Observed(ObservedValue::new(
tagged["Image"].as_str().expect("tagged image ID").to_owned(),
ObservationOrigin::LocalResolution,
)),
&tagged_mounts.field,
&identity,
&mut findings,
);
let tagged_evidence = tagged_evidence
.observed()
.expect("CLI-tagged creation evidence")
.value();
assert!(matches!(
tagged_evidence.image(),
ObservationField::Observed(value)
if *value.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(
tagged_evidence.mount_relabels(),
ObservationField::Observed(value)
if value.value().as_slice()
== [AuthoredMountRelabelHint::Shared { mount_index: 0 }]
));
let provider_mounts = decode_container_mounts(provider, &identity, &mut Vec::new(), &mut findings);
assert!(
provider_mounts
.field
.observed()
.and_then(|value| value.value()[0].selinux_relabel().observed())
.is_none()
);
assert!(matches!(
decode_container_creation_evidence(
container_create_command(provider),
&ObservationField::Observed(ObservedValue::new(
provider["ImageName"].as_str().expect("image name").to_owned(),
ObservationOrigin::Configured,
)),
&ObservationField::Observed(ObservedValue::new(
provider["Image"].as_str().expect("image").to_owned(),
ObservationOrigin::LocalResolution,
)),
&provider_mounts.field,
&identity,
&mut findings,
),
ObservationField::Absent
));
assert!(findings.is_empty());
}
#[test]
fn creation_evidence_preserves_absent_malformed_conflicting_and_bounded_states() {
let identity = ResourceIdentity::new(
ResourceKind::Container,
"container-id".to_owned(),
Some("canary".to_owned()),
);
let configured = ObservationField::Observed(ObservedValue::new(
"example.invalid/configured:1".to_owned(),
ObservationOrigin::Configured,
));
let absent = serde_json::json!({"Config": {}});
let mut findings = Vec::new();
assert!(matches!(
decode_container_creation_evidence(
container_create_command(absent.as_object().expect("object")),
&configured,
&ObservationField::Absent,
&ObservationField::Absent,
&identity,
&mut findings,
),
ObservationField::Absent
));
assert!(findings.is_empty());
let malformed = serde_json::json!({"Config": {"CreateCommand": "not-an-array"}});
assert!(matches!(
decode_container_creation_evidence(
container_create_command(malformed.as_object().expect("object")),
&configured,
&ObservationField::Absent,
&ObservationField::Absent,
&identity,
&mut findings,
),
ObservationField::Malformed
));
assert!(findings.iter().any(|finding| {
finding.code() == DiagnosticCode::ResourceMalformed
&& finding.field_path() == Some("$.Config.CreateCommand")
}));
let conflicting =
serde_json::json!({"Config": {"CreateCommand": ["podman", "run", "SENTINEL_CONFLICTING_IMAGE"]}});
let result = decode_container_creation_evidence(
container_create_command(conflicting.as_object().expect("object")),
&configured,
&ObservationField::Observed(ObservedValue::new(
"sha256:different".to_owned(),
ObservationOrigin::LocalResolution,
)),
&ObservationField::Absent,
&identity,
&mut findings,
);
assert!(matches!(
result,
ObservationField::Observed(ref value)
if matches!(value.value().image(), ObservationField::Observed(image)
if *image.value() == AuthoredImageSpellingHint::Contradictory)
&& matches!(value.value().mount_relabels(), ObservationField::Observed(relabels)
if relabels.value().is_empty())
));
assert!(
findings
.iter()
.any(|finding| finding.code() == DiagnosticCode::CreationEvidenceConflict)
);
let finding_debug = format!("{findings:?}");
assert!(!finding_debug.contains("SENTINEL_CONFLICTING_IMAGE"));
let local_match = serde_json::json!({"Config": {"CreateCommand": ["podman", "run", "sha256:local"]}});
let local_evidence = decode_container_creation_evidence(
container_create_command(local_match.as_object().expect("object")),
&ObservationField::Absent,
&ObservationField::Observed(ObservedValue::new(
"sha256:local".to_owned(),
ObservationOrigin::LocalResolution,
)),
&ObservationField::Absent,
&identity,
&mut Vec::new(),
);
assert!(matches!(
local_evidence,
ObservationField::Observed(ref value)
if matches!(value.value().image(), ObservationField::Observed(image)
if *image.value() == AuthoredImageSpellingHint::MatchesLocalImageId)
));
}
#[test]
fn creation_command_bounds_and_known_value_options_are_closed() {
let mut over_limit = vec![serde_json::json!("podman"), serde_json::json!("run")];
for index in 0..=MAX_AUTHORED_MOUNT_RELABELS {
over_limit.push(serde_json::json!("--volume"));
over_limit.push(serde_json::json!(format!("/bounded/{index}:/container{index}:z")));
}
over_limit.push(serde_json::json!("example.invalid/image:1"));
let over_limit = Value::Array(over_limit);
let CreateCommandParse::Parsed(parsed) = parse_create_command(Some(&over_limit)) else {
panic!("mount bound must not erase the independently parsed image");
};
assert_eq!(parsed.image, "example.invalid/image:1");
assert!(matches!(parsed.mount_relabels, ParsedCreateMountRelabels::Unavailable));
assert!(matches!(
parse_create_command(Some(&Value::Array(vec![
serde_json::json!("podman"),
serde_json::json!("run"),
serde_json::json!("--preserve-fd"),
serde_json::json!("3"),
serde_json::json!("example.invalid/image:1"),
]))),
CreateCommandParse::Parsed(_)
));
let excessive = Value::Array(
(0..=MAX_CREATE_COMMAND_ARGUMENTS)
.map(|_| serde_json::json!("bounded"))
.collect(),
);
assert!(matches!(
parse_create_command(Some(&excessive)),
CreateCommandParse::Unavailable
));
let oversized = Value::Array(vec![
serde_json::json!("podman"),
serde_json::json!("run"),
serde_json::json!("x".repeat(MAX_CREATE_COMMAND_ARGUMENT_BYTES_PER_VALUE + 1)),
]);
assert!(matches!(
parse_create_command(Some(&oversized)),
CreateCommandParse::Unavailable
));
}
#[test]
fn creation_evidence_is_typed_indexed_and_redacts_command_values() {
let response = serde_json::json!({
"Config": {
"CreateCommand": [
"podman",
"create",
"--env",
"SENTINEL_ENV=SENTINEL_VALUE",
"-v",
"/SENTINEL_SOURCE:/container:z",
"example.invalid/canary:1",
"SENTINEL_POST_IMAGE_COMMAND"
]
}
});
let mount = ContainerMountObservation::new(
ContainerMountKind::Bind,
ObservationField::Observed(ObservedValue::new(
ContainerMountSource::LocalBindPath("/SENTINEL_SOURCE".to_owned()),
ObservationOrigin::LocalResolution,
)),
ObservationField::Absent,
ObservationField::Observed(ObservedValue::new(
"/container".to_owned(),
ObservationOrigin::Configured,
)),
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Observed(ObservedValue::new(
ContainerMountSelinuxRelabel::Shared,
ObservationOrigin::Configured,
)),
ObservationField::Absent,
ObservationField::Absent,
);
let identity = ResourceIdentity::new(
ResourceKind::Container,
"container-id".to_owned(),
Some("canary".to_owned()),
);
let mut findings = Vec::new();
let evidence = decode_container_creation_evidence(
container_create_command(response.as_object().expect("object")),
&ObservationField::Observed(ObservedValue::new(
"example.invalid/canary:1".to_owned(),
ObservationOrigin::Configured,
)),
&ObservationField::Absent,
&ObservationField::Observed(ObservedValue::new(vec![mount], ObservationOrigin::Effective)),
&identity,
&mut findings,
);
let observed = evidence.observed().expect("safe evidence");
assert_eq!(observed.origin(), ObservationOrigin::Configured);
assert!(matches!(
observed.value().image(),
ObservationField::Observed(value)
if *value.value() == AuthoredImageSpellingHint::MatchesConfiguredImage
));
assert!(matches!(
observed.value().mount_relabels(),
ObservationField::Observed(value)
if value.value().as_slice() == [AuthoredMountRelabelHint::Shared { mount_index: 0 }]
));
assert!(findings.is_empty());
let debug = format!("{:?}", observed.value());
for protected in [
"SENTINEL_ENV",
"SENTINEL_VALUE",
"SENTINEL_SOURCE",
"SENTINEL_POST_IMAGE_COMMAND",
] {
assert!(!debug.contains(protected), "protected command value escaped debug");
}
}
#[test]
fn kind_safe_resource_observation_constructor_accepts_every_matching_detail_and_rejects_mismatches() {
let details = [
ResourceDetails::Container(ContainerObservation::new(
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
)),
ResourceDetails::Pod(PodObservation::new(
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
)),
ResourceDetails::Network(NetworkObservation::new(
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
)),
ResourceDetails::Volume(VolumeObservation::new(
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
)),
ResourceDetails::Image(ImageObservation::new(ImageObservationFields {
labels: ObservationField::Absent,
repo_tags: ObservationField::Absent,
repo_digests: ObservationField::Absent,
environment: ObservationField::Absent,
digest: ObservationField::Absent,
created: ObservationField::Absent,
author: ObservationField::Absent,
architecture: ObservationField::Absent,
operating_system: ObservationField::Absent,
manifest_type: ObservationField::Absent,
})),
ResourceDetails::Secret(SecretObservation::new(
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
)),
];
for detail in details {
assert!(ResourceObservation::try_new(header(detail.kind()), detail).is_ok());
}
let error = ResourceObservation::try_new(
header(ResourceKind::Container),
ResourceDetails::Pod(PodObservation::new(
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
ObservationField::Absent,
)),
)
.expect_err("kind mismatch must be a structured construction failure");
assert_eq!(error.code(), DiagnosticCode::ResourceMalformed);
}
#[test]
fn native_timestamp_validation_accepts_rfc3339_boundaries_and_rejects_invalid_dates() {
for value in [
"0000-02-29T00:00:00Z",
"2000-02-29T23:59:60Z",
"2024-02-29T12:34:56.123456789+23:59",
"2026-08-20T12:34:56-00:00",
] {
assert!(native_timestamp_is_rfc3339(value), "{value}");
}
for value in [
"2025-02-29T00:00:00Z",
"1900-02-29T00:00:00Z",
"2026-04-31T00:00:00Z",
"2026-01-01T24:00:00Z",
"2026-01-01T00:60:00Z",
"2026-01-01T00:00:61Z",
"2026-01-01T00:00:00.1234567890Z",
"2026-01-01 00:00:00Z",
"2026-01-01T00:00:00+24:00",
] {
assert!(!native_timestamp_is_rfc3339(value), "{value}");
}
}
}
fn first_string(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_array)
.and_then(|values| values.iter().find_map(Value::as_str))
.filter(|value| !value.is_empty())
}
const fn json_value_kind(value: &Value) -> JsonValueKind {
match value {
Value::Null => JsonValueKind::Null,
Value::Bool(_) => JsonValueKind::Boolean,
Value::Number(_) => JsonValueKind::Number,
Value::String(_) => JsonValueKind::String,
Value::Array(_) => JsonValueKind::Array,
Value::Object(_) => JsonValueKind::Object,
}
}