use crate::core::{
client::xpc_client::XpcClient,
error::{ClientError, Result},
};
#[derive(Debug, Clone, Default)]
pub(crate) struct ImageConfig {
pub(crate) entrypoint: Option<Vec<String>>,
pub(crate) cmd: Option<Vec<String>>,
}
impl ImageConfig {
pub(crate) fn effective_command(
&self,
entrypoint_override: Option<&str>,
cmd_override: impl IntoIterator<Item = impl Into<String>>,
) -> Result<(String, Vec<String>)> {
let cmd_override: Vec<String> = cmd_override.into_iter().map(Into::into).collect();
let entrypoint: Option<Vec<String>> = match entrypoint_override {
Some(ep) if !ep.is_empty() => Some(vec![ep.to_string()]),
_ => self.entrypoint.clone(),
};
let cmd: Option<Vec<String>> = if !cmd_override.is_empty() {
Some(cmd_override)
} else {
self.cmd.clone()
};
match (entrypoint, cmd) {
(Some(ep), Some(cmd)) if !ep.is_empty() && !cmd.is_empty() => {
let mut args = ep;
args.extend(cmd);
let exe = args.remove(0);
Ok((exe, args))
}
(Some(ep), _) if !ep.is_empty() => {
let mut args = ep;
let exe = args.remove(0);
Ok((exe, args))
}
(_, Some(cmd)) if !cmd.is_empty() => {
let mut c = cmd;
let exe = c.remove(0);
Ok((exe, c))
}
_ => Err(crate::Error::other(
"no command specified: Apple container XPC does not resolve the image's default \
CMD/ENTRYPOINT and none was provided, use with_cmd() or an Image that provides \
cmd()",
)),
}
}
}
pub(crate) async fn resolve_image_config(
client: &XpcClient,
desc_raw: &str,
platform: Option<&str>,
) -> Result<ImageConfig> {
let desc = parse_descriptor(desc_raw)?;
let config_digest = match desc.media_type.as_str() {
"application/vnd.oci.image.index.v1+json"
| "application/vnd.docker.distribution.manifest.list.v2+json" => {
let index_bytes = client.content_get(&desc.digest).await?;
let manifest_digest = select_manifest_digest(&index_bytes, platform)?;
let manifest_bytes = client.content_get(&manifest_digest).await?;
config_digest_from_manifest(&manifest_bytes)?
}
"application/vnd.oci.image.manifest.v1+json"
| "application/vnd.docker.distribution.manifest.v2+json" => {
let manifest_bytes = client.content_get(&desc.digest).await?;
config_digest_from_manifest(&manifest_bytes)?
}
"application/vnd.oci.image.config.v1+json" => desc.digest,
other => {
return Err(ClientError::Other(format!(
"unsupported image descriptor mediaType: {other}"
))
.into());
}
};
let config_bytes = client.content_get(&config_digest).await?;
parse_image_config(&config_bytes)
}
fn parse_descriptor(desc_raw: &str) -> Result<Descriptor> {
let parsed = nojson::RawJson::parse(desc_raw)
.map_err(|e| ClientError::Json(format!("invalid descriptor JSON: {e}")))?;
let value = parsed.value();
let media_type: String = value
.to_member("mediaType")
.and_then(|m| m.required())
.and_then(TryInto::<String>::try_into)
.map_err(|e| ClientError::Json(format!("descriptor mediaType: {e}")))?;
let digest: String = value
.to_member("digest")
.and_then(|m| m.required())
.and_then(TryInto::<String>::try_into)
.map_err(|e| ClientError::Json(format!("descriptor digest: {e}")))?;
Ok(Descriptor { media_type, digest })
}
struct Descriptor {
media_type: String,
digest: String,
}
fn select_manifest_digest(index_bytes: &[u8], platform: Option<&str>) -> Result<String> {
let text = std::str::from_utf8(index_bytes)
.map_err(|e| ClientError::Json(format!("index is not UTF-8: {e}")))?;
let parsed = nojson::RawJson::parse(text)
.map_err(|e| ClientError::Json(format!("invalid index JSON: {e}")))?;
let manifests: Vec<nojson::RawJsonValue<'_, '_>> = parsed
.value()
.to_member("manifests")
.and_then(|m| m.required())
.and_then(|v| v.to_array())
.map_err(|e| ClientError::Json(format!("index manifests: {e}")))?
.collect();
let (target_os, target_arch) = target_os_and_architecture(platform);
for item in &manifests {
let Some((os, arch)) = manifest_platform_os_arch(item) else {
continue;
};
if os == target_os && arch == target_arch {
return manifest_digest(item);
}
}
if let Some(p) = platform {
return Err(ClientError::Other(format!("no matching manifest for {p}")).into());
}
for preferred in ["arm64", "amd64"] {
for item in &manifests {
let Some((os, arch)) = manifest_platform_os_arch(item) else {
continue;
};
if os == target_os && arch == preferred {
return manifest_digest(item);
}
}
}
for item in &manifests {
let Some(os) = manifest_platform_os(item) else {
continue;
};
if os == target_os && !is_attestation_manifest(item) {
return manifest_digest(item);
}
}
let first = manifests
.iter()
.find(|m| !is_attestation_manifest(m))
.ok_or_else(|| ClientError::Json("index has no valid manifests".into()))?;
manifest_digest(first)
}
fn manifest_digest(item: &nojson::RawJsonValue<'_, '_>) -> Result<String> {
let digest: String = item
.to_member("digest")
.and_then(|m| m.required())
.and_then(TryInto::<String>::try_into)
.map_err(|e| ClientError::Json(format!("manifest digest: {e}")))?;
Ok(digest)
}
fn manifest_platform_os_arch(item: &nojson::RawJsonValue<'_, '_>) -> Option<(String, String)> {
let platform = item.to_member("platform").ok()?.optional()?;
let os = platform
.to_member("os")
.ok()?
.required()
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())?;
let arch = platform
.to_member("architecture")
.ok()?
.required()
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())?;
Some((os, arch))
}
fn manifest_platform_os(item: &nojson::RawJsonValue<'_, '_>) -> Option<String> {
let platform = item.to_member("platform").ok()?.optional()?;
platform
.to_member("os")
.ok()?
.required()
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())
}
fn is_attestation_manifest(item: &nojson::RawJsonValue<'_, '_>) -> bool {
manifest_platform_architecture(item).as_deref() == Some("unknown")
}
fn manifest_platform_architecture(item: &nojson::RawJsonValue<'_, '_>) -> Option<String> {
let platform = item.to_member("platform").ok()?.optional()?;
platform
.to_member("architecture")
.ok()?
.required()
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())
}
fn config_digest_from_manifest(manifest_bytes: &[u8]) -> Result<String> {
let text = std::str::from_utf8(manifest_bytes)
.map_err(|e| ClientError::Json(format!("manifest is not UTF-8: {e}")))?;
let parsed = nojson::RawJson::parse(text)
.map_err(|e| ClientError::Json(format!("invalid manifest JSON: {e}")))?;
let digest: String = parsed
.value()
.to_member("config")
.and_then(|m| m.required())
.and_then(|c| c.to_member("digest"))
.and_then(|m| m.required())
.and_then(TryInto::<String>::try_into)
.map_err(|e| ClientError::Json(format!("manifest config.digest: {e}")))?;
Ok(digest)
}
fn parse_image_config(config_bytes: &[u8]) -> Result<ImageConfig> {
let text = std::str::from_utf8(config_bytes)
.map_err(|e| ClientError::Json(format!("image config is not UTF-8: {e}")))?;
let parsed = nojson::RawJson::parse(text)
.map_err(|e| ClientError::Json(format!("invalid image config JSON: {e}")))?;
let config = parsed
.value()
.to_member("config")
.ok()
.and_then(|m| m.optional());
let entrypoint = config.and_then(|c| string_array(&c, "Entrypoint"));
let cmd = config.and_then(|c| string_array(&c, "Cmd"));
Ok(ImageConfig { entrypoint, cmd })
}
fn string_array(value: &nojson::RawJsonValue, key: &str) -> Option<Vec<String>> {
let arr = value.to_member(key).ok()?.optional()?;
arr.to_array()
.ok()?
.map(|v| TryInto::<String>::try_into(v).ok())
.collect()
}
fn target_os_and_architecture(platform: Option<&str>) -> (&str, &'static str) {
let (os, raw_arch) = platform_os_and_raw_arch(platform);
let arch = match raw_arch {
"" => host_architecture(),
"arm64" | "aarch64" => "arm64",
"amd64" | "x86_64" => "amd64",
_ => host_architecture(),
};
(os, arch)
}
fn platform_os_and_raw_arch(platform: Option<&str>) -> (&str, &str) {
match platform {
None => ("linux", ""),
Some(p) => {
let mut parts = p.split('/');
let first = parts.next().unwrap_or("");
match parts.next() {
Some(arch) => (first, arch),
None => ("linux", first),
}
}
}
}
fn host_architecture() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "amd64",
_ => "arm64",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effective_command_uses_user_entrypoint_and_user_cmd() {
let cfg = ImageConfig {
entrypoint: Some(vec!["/bin/sh".into(), "-c".into()]),
cmd: Some(vec!["echo".into(), "image".into()]),
};
let (exe, args) = cfg
.effective_command(Some("/bin/bash"), ["-c", "user"])
.expect("処理に失敗しないこと");
assert_eq!(exe, "/bin/bash");
assert_eq!(args, vec!["-c", "user"]);
}
#[test]
fn effective_command_appends_image_cmd_to_image_entrypoint() {
let cfg = ImageConfig {
entrypoint: Some(vec!["/bin/sh".into(), "-c".into()]),
cmd: Some(vec!["echo".into(), "hello".into()]),
};
let (exe, args) = cfg
.effective_command(None, std::iter::empty::<String>())
.expect("処理に失敗しないこと");
assert_eq!(exe, "/bin/sh");
assert_eq!(args, vec!["-c", "echo", "hello"]);
}
#[test]
fn effective_command_uses_image_cmd_when_no_entrypoint() {
let cfg = ImageConfig {
entrypoint: None,
cmd: Some(vec!["nginx".into(), "-g".into(), "daemon off;".into()]),
};
let (exe, args) = cfg
.effective_command(None, std::iter::empty::<String>())
.expect("処理に失敗しないこと");
assert_eq!(exe, "nginx");
assert_eq!(args, vec!["-g", "daemon off;"]);
}
#[test]
fn effective_command_prefers_user_cmd_over_image_cmd() {
let cfg = ImageConfig {
entrypoint: None,
cmd: Some(vec!["image-cmd".into()]),
};
let (exe, args) = cfg
.effective_command(None, ["user-cmd"])
.expect("処理に失敗しないこと");
assert_eq!(exe, "user-cmd");
assert_eq!(args, Vec::<String>::new());
}
#[test]
fn effective_command_errors_when_nothing_available() {
let cfg = ImageConfig::default();
assert!(
cfg.effective_command(None, std::iter::empty::<String>())
.is_err()
);
}
#[test]
fn parse_descriptor_extracts_media_type_and_digest() {
let raw = r#"{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:abc","size":123}"#;
let desc = parse_descriptor(raw).expect("処理に失敗しないこと");
assert_eq!(desc.media_type, "application/vnd.oci.image.index.v1+json");
assert_eq!(desc.digest, "sha256:abc");
}
#[test]
fn select_manifest_digest_matches_platform() {
let index = r#"{"manifests":[
{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}},
{"digest":"sha256:arm64","platform":{"architecture":"arm64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), Some("linux/arm64"))
.expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:arm64");
}
#[test]
fn select_manifest_digest_defaults_to_host_arch() {
let index = r#"{"manifests":[
{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}},
{"digest":"sha256:arm64","platform":{"architecture":"arm64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), None).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:arm64");
}
#[test]
fn platform_os_and_raw_arch_uses_second_component_as_arch() {
assert_eq!(
platform_os_and_raw_arch(Some("linux/arm64/v8")),
("linux", "arm64")
);
assert_eq!(
target_os_and_architecture(Some("linux/arm64/v8")),
("linux", "arm64")
);
assert_eq!(platform_os_and_raw_arch(Some("amd64")), ("linux", "amd64"));
assert_eq!(
platform_os_and_raw_arch(Some("linux/arm/v7")),
("linux", "arm")
);
}
#[test]
fn select_manifest_digest_ignores_variant_suffix() {
let index = r#"{"manifests":[
{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}},
{"digest":"sha256:arm64","platform":{"architecture":"arm64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), Some("linux/arm64/v8"))
.expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:arm64");
}
#[test]
fn select_manifest_digest_matches_os_on_primary_path() {
let index = r#"{"manifests":[
{"digest":"sha256:win-amd64","platform":{"architecture":"amd64","os":"windows"}},
{"digest":"sha256:linux-amd64","platform":{"architecture":"amd64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), Some("linux/amd64"))
.expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-amd64");
}
#[test]
fn select_manifest_digest_matches_os_on_preferred_fallback() {
let index = r#"{"manifests":[
{"digest":"sha256:win-arm64","platform":{"architecture":"arm64","os":"windows"}},
{"digest":"sha256:linux-amd64","platform":{"architecture":"amd64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), None).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-amd64");
}
#[test]
fn select_manifest_digest_soft_first_matches_os() {
let index = r#"{"manifests":[
{"digest":"sha256:win-arm64","platform":{"architecture":"arm64","os":"windows"}},
{"digest":"sha256:win-amd64","platform":{"architecture":"amd64","os":"windows"}},
{"digest":"sha256:linux-s390x","platform":{"architecture":"s390x","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), None).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-s390x");
}
#[test]
fn select_manifest_digest_single_component_defaults_os_linux() {
let index = r#"{"manifests":[
{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}},
{"digest":"sha256:arm64","platform":{"architecture":"arm64","os":"linux"}}
]}"#;
let digest =
select_manifest_digest(index.as_bytes(), Some("amd64")).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:amd64");
}
#[test]
fn select_manifest_digest_unknown_arch_stays_on_linux() {
assert_eq!(
platform_os_and_raw_arch(Some("linux/arm/v7")),
("linux", "arm")
);
let index = r#"{"manifests":[
{"digest":"sha256:win-amd64","platform":{"architecture":"amd64","os":"windows"}},
{"digest":"sha256:linux-arm64","platform":{"architecture":"arm64","os":"linux"}},
{"digest":"sha256:linux-amd64","platform":{"architecture":"amd64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), Some("linux/arm/v7"))
.expect("処理に失敗しないこと");
assert!(
digest == "sha256:linux-arm64" || digest == "sha256:linux-amd64",
"windows を選ばず linux 側であること: {digest}"
);
}
#[test]
fn select_manifest_digest_skips_entries_missing_os() {
let index = r#"{"manifests":[
{"digest":"sha256:arch-only","platform":{"architecture":"amd64"}},
{"digest":"sha256:linux-amd64","platform":{"architecture":"amd64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), Some("linux/amd64"))
.expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-amd64");
}
#[test]
fn config_digest_from_manifest_extracts_config_digest() {
let manifest = r#"{"schemaVersion":2,"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:cfg","size":1}}"#;
let digest =
config_digest_from_manifest(manifest.as_bytes()).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:cfg");
}
#[test]
fn parse_image_config_extracts_entrypoint_and_cmd() {
let config = r#"{"config":{"Entrypoint":["/bin/sh","-c"],"Cmd":["echo","hi"]}}"#;
let cfg = parse_image_config(config.as_bytes()).expect("処理に失敗しないこと");
assert_eq!(cfg.entrypoint, Some(vec!["/bin/sh".into(), "-c".into()]));
assert_eq!(cfg.cmd, Some(vec!["echo".into(), "hi".into()]));
}
#[test]
fn parse_image_config_handles_missing_fields() {
let config = r#"{"config":{}}"#;
let cfg = parse_image_config(config.as_bytes()).expect("処理に失敗しないこと");
assert_eq!(cfg.entrypoint, None);
assert_eq!(cfg.cmd, None);
}
#[test]
fn select_manifest_digest_skips_attestation_at_head() {
let index = r#"{"manifests":[
{"digest":"sha256:attestation","platform":{"architecture":"unknown","os":"unknown"}},
{"digest":"sha256:linux-amd64","platform":{"architecture":"amd64","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), Some("linux/amd64"))
.expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-amd64");
}
#[test]
fn select_manifest_digest_errors_when_all_attestation() {
let index = r#"{"manifests":[
{"digest":"sha256:att1","platform":{"architecture":"unknown","os":"unknown"}},
{"digest":"sha256:att2","platform":{"architecture":"unknown","os":"unknown"}}
]}"#;
let err = select_manifest_digest(index.as_bytes(), None)
.expect_err("全 attestation はエラーであること");
assert!(
err.to_string().contains("no valid manifests"),
"エラーメッセージに no valid manifests が含まれること: {err}"
);
}
#[test]
fn select_manifest_digest_soft_first_skips_unknown_arch() {
let index = r#"{"manifests":[
{"digest":"sha256:linux-unknown","platform":{"architecture":"unknown","os":"linux"}},
{"digest":"sha256:linux-s390x","platform":{"architecture":"s390x","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), None).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-s390x");
}
#[test]
fn select_manifest_digest_hard_first_skips_attestation() {
let index = r#"{"manifests":[
{"digest":"sha256:attestation","platform":{"architecture":"unknown","os":"unknown"}},
{"digest":"sha256:win-amd64","platform":{"architecture":"amd64","os":"windows"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), None).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:win-amd64");
}
#[test]
fn select_manifest_digest_soft_first_keeps_missing_arch() {
let index = r#"{"manifests":[
{"digest":"sha256:linux-no-arch","platform":{"os":"linux"}},
{"digest":"sha256:linux-s390x","platform":{"architecture":"s390x","os":"linux"}}
]}"#;
let digest = select_manifest_digest(index.as_bytes(), None).expect("処理に失敗しないこと");
assert_eq!(digest, "sha256:linux-no-arch");
}
#[test]
fn select_manifest_digest_errors_when_explicit_platform_has_no_match() {
let index = r#"{"manifests":[
{"digest":"sha256:linux-arm64","platform":{"architecture":"arm64","os":"linux"}},
{"digest":"sha256:linux-s390x","platform":{"architecture":"s390x","os":"linux"}}
]}"#;
let err = select_manifest_digest(index.as_bytes(), Some("linux/amd64"))
.expect_err("amd64 が無い明示指定はエラーになること");
assert!(
err.to_string()
.contains("no matching manifest for linux/amd64"),
"エラーメッセージに platform が含まれること: {err}"
);
let win_only = r#"{"manifests":[
{"digest":"sha256:win-amd64","platform":{"architecture":"amd64","os":"windows"}}
]}"#;
let err = select_manifest_digest(win_only.as_bytes(), Some("linux/amd64"))
.expect_err("linux/amd64 が無い明示指定は os 不問でもエラーになること");
assert!(
err.to_string()
.contains("no matching manifest for linux/amd64"),
"hard フォールバック抑止のエラーであること: {err}"
);
}
}