use kernel::{AxError, ContainerLimits};
use serde_json::{Value, json};
use super::{ContainerEngine, WORKDIR, denied};
pub(super) fn image(bytes: &[u8], limits: &ContainerLimits) -> Result<(), AxError> {
let value = first(bytes)?;
let pinned = value.get("Id").and_then(Value::as_str).is_some_and(|id| {
id == limits.image.as_str() || limits.image.as_str().strip_prefix("sha256:") == Some(id)
});
let config = value.get("Config").and_then(Value::as_object);
let volumes = config.and_then(|config| config.get("Volumes"));
if !pinned
|| config.is_none()
|| !matches!(volumes, None | Some(Value::Null))
&& !volumes
.is_some_and(|value| value.as_object().is_some_and(serde_json::Map::is_empty))
{
return Err(denied(
"inspect the container image",
"the image identity is unconfirmed or it declares writable volumes",
));
}
Ok(())
}
pub(super) fn stopped(
bytes: &[u8],
limits: &ContainerLimits,
engine: ContainerEngine,
copy: &std::path::Path,
) -> Result<(), AxError> {
let value = first(bytes)?;
let host = value
.get("HostConfig")
.ok_or_else(|| denied("inspect container limits", "missing HostConfig"))?;
let config = value
.get("Config")
.ok_or_else(|| denied("inspect container limits", "missing Config"))?;
let cpu = match engine {
ContainerEngine::Docker => {
host.get("NanoCpus").and_then(Value::as_u64)
== u64::from(limits.cpu_millis.get()).checked_mul(1_000_000)
}
ContainerEngine::Podman => host
.get("CpuQuota")
.and_then(Value::as_u64)
.zip(host.get("CpuPeriod").and_then(Value::as_u64))
.is_some_and(|(quota, period)| {
period > 0
&& quota.checked_mul(u64::from(super::MILLICPU_PER_CPU))
== period.checked_mul(u64::from(limits.cpu_millis.get()))
}),
};
let mounts = value.get("Mounts").and_then(Value::as_array);
let mount = mounts.and_then(|mounts| mounts.first());
let matches_copy = copy.to_str().is_some_and(|copy| {
mount
.and_then(|mount| mount.get("Source"))
.and_then(Value::as_str)
== Some(copy)
});
let caps = host.get("CapDrop").and_then(Value::as_array);
let security = host.get("SecurityOpt").and_then(Value::as_array);
let caps_dropped = match engine {
ContainerEngine::Docker => caps.is_some_and(|caps| {
caps.iter().any(|cap| {
cap.as_str()
.is_some_and(|cap| cap.eq_ignore_ascii_case("ALL"))
})
}),
ContainerEngine::Podman => ["EffectiveCaps", "BoundingCaps"].iter().all(|field| {
value
.get(field)
.is_some_and(|caps| caps.is_null() || caps.as_array().is_some_and(Vec::is_empty))
}),
};
if !cpu
|| host.get("Memory") != Some(&json!(limits.memory_bytes.get()))
|| host.get("MemorySwap") != Some(&json!(limits.memory_bytes.get()))
|| host.get("PidsLimit") != Some(&json!(limits.pids.get()))
|| host.get("NetworkMode").and_then(Value::as_str) != Some("none")
|| host.get("ReadonlyRootfs").and_then(Value::as_bool) != Some(true)
|| config.get("User").and_then(Value::as_str) != Some(&limits.user.to_string())
|| config.get("WorkingDir").and_then(Value::as_str) != Some(WORKDIR)
|| value.pointer("/State/Running").and_then(Value::as_bool) != Some(false)
|| mounts.is_none_or(|mounts| mounts.len() != 1)
|| !matches_copy
|| mount
.and_then(|mount| mount.get("Destination"))
.and_then(Value::as_str)
!= Some(WORKDIR)
|| mount
.and_then(|mount| mount.get("Type"))
.and_then(Value::as_str)
!= Some("bind")
|| !caps_dropped
|| !security.is_some_and(|security| {
security.iter().any(|option| {
matches!(
option.as_str(),
Some("no-new-privileges" | "no-new-privileges:true")
)
})
})
{
return Err(denied(
"inspect container limits",
"the stopped container does not retain every requested boundary",
));
}
Ok(())
}
pub(super) fn state(bytes: &[u8]) -> Result<(bool, i32), AxError> {
let value = first(bytes)?;
let running = value
.pointer("/State/Running")
.and_then(Value::as_bool)
.ok_or_else(|| denied("read the container state", "missing running state"))?;
let status = value.pointer("/State/Status").and_then(Value::as_str);
if !matches!(
(running, status),
(true, Some("running" | "paused" | "restarting")) | (false, Some("exited" | "dead"))
) {
return Err(denied(
"read the container result",
"the daemon has not confirmed a running or terminal target",
));
}
let code = value
.pointer("/State/ExitCode")
.and_then(Value::as_i64)
.ok_or_else(|| denied("read the container result", "missing target exit code"))?;
let code =
i32::try_from(code).map_err(|err| denied("read the container result", err.to_string()))?;
Ok((running, code))
}
fn first(bytes: &[u8]) -> Result<Value, AxError> {
let value: Value = serde_json::from_slice(bytes)
.map_err(|err| denied("inspect the container", err.to_string()))?;
value
.as_array()
.filter(|values| values.len() == 1)
.and_then(|values| values.first())
.cloned()
.ok_or_else(|| {
denied(
"inspect the container",
"expected exactly one identified object",
)
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "test code")]
mod tests {
#[test]
fn podman_image_metadata_accepts_only_the_pinned_digest_without_volumes() {
let limits: kernel::ContainerLimits = serde_json::from_value(serde_json::json!({
"image": format!("sha256:{}", "a".repeat(64)), "user":1000,
"cpu_millis":1250, "memory_bytes":67108864, "pids":64
}))
.unwrap();
for config in [
serde_json::json!({}),
serde_json::json!({"Volumes":null}),
serde_json::json!({"Volumes":{}}),
] {
let bytes = serde_json::to_vec(&serde_json::json!([{
"Id":"a".repeat(64), "Config":config
}]))
.unwrap();
assert!(super::image(&bytes, &limits).is_ok());
}
for config in [
serde_json::json!(null),
serde_json::json!({"Volumes":[]}),
serde_json::json!({"Volumes":{"/extra":{}}}),
] {
let bytes = serde_json::to_vec(&serde_json::json!([{
"Id":limits.image.as_str(), "Config":config
}]))
.unwrap();
assert!(super::image(&bytes, &limits).is_err());
}
assert!(super::image(br#"[{"Id":"wrong","Config":{}}]"#, &limits).is_err());
}
#[test]
fn podman_empty_capability_sets_are_not_missing_evidence() {
let limits: kernel::ContainerLimits = serde_json::from_value(serde_json::json!({
"image": format!("sha256:{}", "a".repeat(64)), "user":1000,
"cpu_millis":1250, "memory_bytes":67108864, "pids":64
}))
.unwrap();
let copy = tempfile::tempdir().unwrap();
let mut value = serde_json::json!({
"Config":{"User":"1000","WorkingDir":"/work"},
"HostConfig":{"CpuQuota":125000,"CpuPeriod":100000,"Memory":67108864,
"MemorySwap":67108864,"PidsLimit":64,"NetworkMode":"none",
"ReadonlyRootfs":true,"CapDrop":["CAP_CHOWN"],
"SecurityOpt":["no-new-privileges"]},
"Mounts":[{"Source":copy.path(),"Destination":"/work","Type":"bind"}],
"State":{"Running":false},"EffectiveCaps":null,"BoundingCaps":null
});
for empty in [serde_json::json!(null), serde_json::json!([])] {
value["EffectiveCaps"] = empty.clone();
value["BoundingCaps"] = empty;
assert!(
super::stopped(
&serde_json::to_vec(&[&value]).unwrap(),
&limits,
super::ContainerEngine::Podman,
copy.path()
)
.is_ok()
);
}
for field in ["EffectiveCaps", "BoundingCaps"] {
for invalid in [
serde_json::json!(""),
serde_json::json!({}),
serde_json::json!(["CAP_CHOWN"]),
] {
let mut unsafe_value = value.clone();
unsafe_value[field] = invalid;
assert!(
super::stopped(
&serde_json::to_vec(&[unsafe_value]).unwrap(),
&limits,
super::ContainerEngine::Podman,
copy.path()
)
.is_err()
);
}
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
super::stopped(
&serde_json::to_vec(&[missing]).unwrap(),
&limits,
super::ContainerEngine::Podman,
copy.path()
)
.is_err()
);
}
}
#[test]
fn a_created_container_with_zero_exit_has_no_target_result() {
let bytes = br#"[{"State":{"Status":"created","Running":false,"ExitCode":0}}]"#;
assert!(
super::state(bytes).is_err(),
"a target that the daemon never started cannot be a successful exit zero"
);
}
}