use std::collections::HashMap;
use std::io::Read;
use std::net::IpAddr;
use std::os::fd::FromRawFd;
use std::time::Duration;
use nojson::DisplayJson;
use crate::core::{
client::ContainerSnapshot,
error::{ClientError, Result},
ports::{ContainerPort, Ports},
};
use crate::xpc::{self, IMAGE_SERVICE, KeyValue, SERVICE_NAME, XpcConn, id_key, j, k, s};
#[derive(Clone)]
pub(crate) struct XpcClient;
fn xpc_timeout_for_grace(grace_seconds: u64) -> Duration {
crate::xpc::DEFAULT_TIMEOUT
.max(Duration::from_secs(grace_seconds.saturating_add(30)).min(crate::xpc::LONG_TIMEOUT))
}
fn absolutize_host_path(path: &std::path::Path) -> Result<std::path::PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
let cwd = std::env::current_dir()
.map_err(|e| ClientError::Other(format!("failed to resolve current directory: {e}")))?;
Ok(cwd.join(path))
}
fn host_path_for_xpc(path: &std::path::Path) -> Result<&str> {
path.to_str().ok_or_else(|| {
ClientError::Other(format!("host path is not valid UTF-8: {}", path.display())).into()
})
}
impl XpcClient {
pub(crate) fn wait_blocking(id: &str, process_id: &str) -> Result<i64> {
let conn = XpcConn::connect(SERVICE_NAME)?;
let reply = conn.send_with_timeout(
"containerWait",
&[(id_key(), s(id)), (k("processIdentifier"), s(process_id))],
crate::xpc::LONG_TIMEOUT,
)?;
reply.try_int64(&k("exitCode"))
}
pub(crate) fn wait_blocking_with_timeout(
id: &str,
process_id: &str,
timeout: Duration,
) -> Result<i64> {
let conn = XpcConn::connect(SERVICE_NAME)?;
let reply = conn.send_with_timeout(
"containerWait",
&[(id_key(), s(id)), (k("processIdentifier"), s(process_id))],
timeout,
)?;
reply.try_int64(&k("exitCode"))
}
pub(crate) async fn stop(&self, id: &str, timeout_seconds: Option<i32>) -> Result<()> {
let (signal, timeout) = match timeout_seconds {
Some(0) => ("SIGKILL".to_string(), 0),
Some(t) if t < 0 => ("SIGTERM".to_string(), i32::MAX as u64),
Some(t) => ("SIGTERM".to_string(), t as u64),
None => ("SIGTERM".to_string(), 30),
};
let xpc_timeout = xpc_timeout_for_grace(timeout);
let id = id.to_string();
let stop_options = j(&StopOptions { signal, timeout });
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
let result = conn.send_with_timeout(
"containerStop",
&[
(id_key(), s(&id)),
(k("stopOptions"), KeyValue::Data(stop_options)),
],
xpc_timeout,
);
match result {
Ok(_) => Ok(()),
Err(e) if is_not_found_error(&e) => Ok(()),
Err(e) => Err(e),
}
})
.await?
}
pub(crate) async fn copy_in(
&self,
id: &str,
source: &std::path::Path,
destination: &str,
mode: u32,
) -> Result<()> {
let id = id.to_string();
let source = absolutize_host_path(source)?;
let source = host_path_for_xpc(&source)?.to_string();
let destination = destination.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
conn.send(
"containerCopyIn",
&[
(id_key(), s(&id)),
(k("sourcePath"), s(&source)),
(k("destinationPath"), s(&destination)),
(k("fileMode"), KeyValue::UInt64(u64::from(mode))),
(k("createParents"), KeyValue::Bool(true)),
],
)?;
Ok(())
})
.await?
}
pub(crate) async fn copy_out(
&self,
id: &str,
source: &std::path::Path,
destination: &std::path::Path,
) -> Result<()> {
let id = id.to_string();
let source = host_path_for_xpc(source)?.to_string();
let destination = absolutize_host_path(destination)?;
let destination = host_path_for_xpc(&destination)?.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
conn.send(
"containerCopyOut",
&[
(id_key(), s(&id)),
(k("sourcePath"), s(&source)),
(k("destinationPath"), s(&destination)),
(k("createParents"), KeyValue::Bool(true)),
],
)?;
Ok(())
})
.await?
}
pub(crate) async fn remove(&self, id: &str, force: bool) -> Result<()> {
let id = id.to_string();
tokio::task::spawn_blocking(move || Self::remove_blocking(&id, force)).await?
}
pub(crate) fn remove_blocking(id: &str, force: bool) -> Result<()> {
let conn = XpcConn::connect(SERVICE_NAME)?;
let result = conn.send(
"containerDelete",
&[(id_key(), s(id)), (k("forceDelete"), KeyValue::Bool(force))],
);
match result {
Ok(_) => Ok(()),
Err(e) if is_not_found_error(&e) => Ok(()),
Err(e) => Err(e),
}
}
pub(crate) async fn logs(&self, id: &str) -> Result<(std::os::fd::RawFd, std::os::fd::RawFd)> {
let id = id.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
let reply = conn.send("containerLogs", &[(id_key(), s(&id))])?;
let fds = reply.log_fds();
if fds.len() < 2 {
close_valid_fds(&fds);
return Err(
ClientError::Other("containerLogs did not return enough fds".into()).into(),
);
}
if fds[0] < 0 || fds[1] < 0 {
close_valid_fds(&fds);
return Err(
ClientError::Other("containerLogs returned an invalid fd".into()).into(),
);
}
Ok((fds[0], fds[1]))
})
.await?
}
pub(crate) async fn resolve_volume(&self, name: &str) -> Result<VolumeResolution> {
let name = name.to_string();
if !is_valid_volume_name(&name) {
return Err(ClientError::Other(format!(
"invalid volume name {name:?}: must match ^[A-Za-z0-9][A-Za-z0-9_.-]*$ and be at most 255 characters"
))
.into());
}
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
let reply = conn.send(
"volumeCreate",
&[(k("volumeName"), s(&name)), (k("volumeDriver"), s("local"))],
);
let data = match reply {
Ok(r) => r.data(&k("volume")),
Err(e) if is_volume_already_exists_error(&e) => conn
.send("volumeInspect", &[(k("volumeName"), s(&name))])?
.data(&k("volume")),
Err(e) => return Err(e),
};
let data = data.ok_or_else(|| {
crate::core::error::Error::Client(ClientError::Other(format!(
"volume '{name}' resolution did not return volume"
)))
})?;
parse_volume_configuration(&data)
})
.await?
}
pub(crate) async fn container_state(&self, id: &str) -> Result<ContainerSnapshot> {
let id = id.to_string();
tokio::task::spawn_blocking(move || {
with_first_container(&id, |item| {
let state: String = item
.to_member("status")
.and_then(|m| m.required())
.and_then(|v| v.try_into())
.unwrap_or_else(|_| "unknown".into());
let running = state == "running";
let ports = parse_published_ports(item);
Ok(ContainerSnapshot { running, ports })
})
})
.await?
}
pub(crate) async fn bridge_ip_address(&self, id: &str) -> Result<IpAddr> {
let id = id.to_string();
tokio::task::spawn_blocking(move || {
with_first_container(&id, |item| {
let networks = item
.to_member("networks")
.ok()
.and_then(|m| m.optional())
.and_then(|v| v.to_array().ok())
.ok_or_else(|| ClientError::Other("no network attachments".into()))?;
let first = networks
.into_iter()
.next()
.ok_or_else(|| ClientError::Other("no network attachments".into()))?;
let addr_str: String = first
.to_member("ipv4Address")
.and_then(|m| m.required())
.and_then(|v| v.try_into())
.map_err(|e| ClientError::Json(e.to_string()))?;
let addr_only = addr_str
.split_once('/')
.map_or(addr_str.as_str(), |(addr, _)| addr);
addr_only.parse::<IpAddr>().map_err(|e| {
ClientError::Other(format!("invalid bridge ip address: {e}")).into()
})
})
})
.await?
}
pub(crate) async fn gateway_ip_address(&self, id: &str) -> Result<IpAddr> {
let id = id.to_string();
tokio::task::spawn_blocking(move || {
with_first_container(&id, |item| {
let networks = item
.to_member("networks")
.ok()
.and_then(|m| m.optional())
.and_then(|v| v.to_array().ok())
.ok_or_else(|| ClientError::Other("no network attachments".into()))?;
let first = networks
.into_iter()
.next()
.ok_or_else(|| ClientError::Other("no network attachments".into()))?;
let addr_str: String = first
.to_member("ipv4Gateway")
.and_then(|m| m.required())
.and_then(|v| v.try_into())
.map_err(|e| ClientError::Json(e.to_string()))?;
let addr_only = addr_str
.split_once('/')
.map_or(addr_str.as_str(), |(addr, _)| addr);
addr_only.parse::<IpAddr>().map_err(|e| {
ClientError::Other(format!("invalid gateway ip address: {e}")).into()
})
})
})
.await?
}
pub(crate) async fn ports(&self, id: &str) -> Result<Ports> {
let snapshot = self.container_state(id).await?;
Ok(snapshot.ports)
}
pub(crate) async fn exec(
&self,
id: &str,
cmd: &[String],
environment: Vec<String>,
) -> Result<XpcExecResult> {
if cmd.is_empty() {
return Err(ClientError::Other("exec requires at least one argument".into()).into());
}
let id = id.to_string();
let executable = cmd[0].clone();
let arguments: Vec<String> = if cmd.len() > 1 {
cmd[1..].to_vec()
} else {
vec![]
};
let pid = format!("exec-{}", crate::core::util::unique_suffix());
tokio::task::spawn_blocking(move || {
let (out_read, out_write) = create_pipe()?;
let (err_read, err_write) = create_pipe()?;
let conn = XpcConn::connect(SERVICE_NAME)?;
let cfg = ProcCfg {
executable,
arguments,
environment,
working_directory: "/".into(),
};
use std::os::fd::AsRawFd;
conn.send(
"containerCreateProcess",
&[
(id_key(), s(&id)),
(k("processIdentifier"), s(&pid)),
(k("processConfig"), KeyValue::Data(j(&cfg))),
(k("stdout"), KeyValue::Fd(out_write.as_raw_fd())),
(k("stderr"), KeyValue::Fd(err_write.as_raw_fd())),
],
)?;
drop(out_write);
drop(err_write);
conn.send(
"containerStartProcess",
&[(id_key(), s(&id)), (k("processIdentifier"), s(&pid))],
)?;
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let out_cancel = cancel.clone();
let err_cancel = cancel.clone();
let out_handle =
std::thread::spawn(move || read_file_to_vec_cancellable(out_read, out_cancel));
let err_handle =
std::thread::spawn(move || read_file_to_vec_cancellable(err_read, err_cancel));
let reply = match conn.send_with_timeout(
"containerWait",
&[(id_key(), s(&id)), (k("processIdentifier"), s(&pid))],
crate::xpc::LONG_TIMEOUT,
) {
Ok(r) => r,
Err(e) => {
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
return Err(e);
}
};
let stdout = out_handle
.join()
.map_err(|_| ClientError::Other("stdout reader thread panicked".into()))??
.expect("正常系の exec では読み取りが打ち切られないため Some になること");
let stderr = err_handle
.join()
.map_err(|_| ClientError::Other("stderr reader thread panicked".into()))??
.expect("正常系の exec では読み取りが打ち切られないため Some になること");
let exit_code = reply.try_int64(&k("exitCode"))?;
Ok(XpcExecResult {
exit_code: Some(exit_code),
stdout,
stderr,
})
})
.await?
}
pub(crate) async fn pull_image(&self, image: &str, platform_arch: Option<&str>) -> Result<()> {
let image = normalize_image_reference(image);
let oci_platform = platform_arch.map(oci_platform_json);
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(IMAGE_SERVICE)?;
let mut args = vec![
(k("imageReference"), s(&image)),
(k("insecureFlag"), KeyValue::Bool(false)),
(k("maxConcurrentDownloads"), KeyValue::Int64(3)),
];
if let Some(pf) = oci_platform {
args.push((k("ociPlatform"), KeyValue::Data(pf)));
}
conn.send_with_timeout("imagePull", &args, crate::xpc::LONG_TIMEOUT)?;
Ok(())
})
.await?
}
pub(crate) async fn get_default_kernel(&self) -> Result<Vec<u8>> {
let pf = oci_platform_json("arm64");
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
let reply = conn.send(
"getDefaultKernel",
&[(k("systemPlatform"), KeyValue::Data(pf))],
)?;
reply
.data(&k("kernel"))
.ok_or_else(|| ClientError::Other("no kernel".into()).into())
})
.await?
}
pub(crate) async fn resolve_image_descriptor(&self, image: &str) -> Result<String> {
let image = image.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(IMAGE_SERVICE)?;
let reply = conn.send("imageList", &[])?;
let data = reply.data(&k("imageDescriptions")).unwrap_or_default();
match_image_descriptor(&data, &image).map_err(Into::into)
})
.await?
}
pub(crate) async fn content_get(&self, digest: &str) -> Result<Vec<u8>> {
let digest = digest.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(IMAGE_SERVICE)?;
let reply = conn.send("contentGet", &[(k("digest"), s(&digest))])?;
let path = reply.string(&k("contentPath")).ok_or_else(|| {
ClientError::Other("contentGet did not return contentPath".into())
})?;
std::fs::read(&path).map_err(|e| {
ClientError::Other(format!(
"failed to read content for {digest} at {path}: {e}"
))
.into()
})
})
.await?
}
pub(crate) async fn create_container(
&self,
container_cfg: &impl DisplayJson,
kernel: Vec<u8>,
) -> Result<()> {
let container_cfg = j(container_cfg);
let opts = j(&CreateOpts { auto_remove: false });
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
conn.send(
"containerCreate",
&[
(k("containerConfig"), KeyValue::Data(container_cfg)),
(k("kernel"), KeyValue::Data(kernel)),
(k("containerOptions"), KeyValue::Data(opts)),
],
)?;
Ok(())
})
.await?
}
pub(crate) async fn bootstrap_container(&self, id: &str) -> Result<()> {
let id = id.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
conn.send(
"containerBootstrap",
&[
(id_key(), s(&id)),
(k("dynamicEnv"), KeyValue::Data(b"{}".to_vec())),
],
)?;
Ok(())
})
.await?
}
pub(crate) async fn start_process(&self, id: &str) -> Result<()> {
let id = id.to_string();
tokio::task::spawn_blocking(move || {
let conn = XpcConn::connect(SERVICE_NAME)?;
conn.send(
"containerStartProcess",
&[(id_key(), s(&id)), (k("processIdentifier"), s(&id))],
)?;
Ok(())
})
.await?
}
}
fn with_first_container<F, R>(id: &str, f: F) -> Result<R>
where
F: FnOnce(&nojson::RawJsonValue<'_, '_>) -> Result<R>,
{
let conn = XpcConn::connect(SERVICE_NAME)?;
let filters = j(&xpc::Filters {
ids: vec![id.to_string()],
labels: HashMap::new(),
});
let reply = conn.send(
"containerList",
&[(k("listFilters"), KeyValue::Data(filters))],
)?;
let data = reply.data(&k("containers")).unwrap_or_default();
if data.is_empty() {
return Err(ClientError::ContainerNotFound(id.to_string()).into());
}
let text = std::str::from_utf8(&data).unwrap_or("");
let parsed = nojson::RawJson::parse(text).map_err(|e| ClientError::Json(e.to_string()))?;
let arr = parsed
.value()
.to_array()
.map_err(|e| ClientError::Json(e.to_string()))?;
let item = arr
.into_iter()
.next()
.ok_or_else(|| ClientError::ContainerNotFound(id.to_string()))?;
f(&item)
}
fn is_not_found_error(e: &crate::core::error::Error) -> bool {
match e {
crate::core::error::Error::Client(ClientError::ContainerNotFound(_)) => true,
crate::core::error::Error::Client(ClientError::Xpc(msg)) => {
msg.starts_with("XPC error notFound")
}
_ => false,
}
}
fn is_volume_already_exists_error(e: &crate::core::error::Error) -> bool {
match e {
crate::core::error::Error::Client(ClientError::Xpc(msg)) => msg.contains("already exists"),
_ => false,
}
}
fn is_valid_volume_name(name: &str) -> bool {
if name.is_empty() || name.len() > 255 {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}
#[derive(Debug)]
pub(crate) struct VolumeResolution {
pub(crate) source: String,
pub(crate) format: String,
}
pub(crate) async fn resolve_volumes<I: crate::Image>(
client: &XpcClient,
req: &crate::ContainerRequest<I>,
) -> Result<std::collections::HashMap<String, VolumeResolution>> {
let mut names: Vec<String> = req
.mounts()
.filter(|m| matches!(m.mount_type(), crate::core::mounts::MountType::Volume))
.filter_map(|m| m.source().map(str::to_owned))
.collect();
names.sort();
names.dedup();
let mut resolutions = std::collections::HashMap::new();
for name in names {
let resolution = client
.resolve_volume(&name)
.await
.map_err(|e| crate::Error::other(format!("failed to resolve volume '{name}': {e}")))?;
resolutions.insert(name.clone(), resolution);
}
Ok(resolutions)
}
fn parse_volume_configuration(data: &[u8]) -> Result<VolumeResolution> {
let text = std::str::from_utf8(data)
.map_err(|e| ClientError::Json(format!("volume configuration is not UTF-8: {e}")))?;
let parsed = nojson::RawJson::parse(text).map_err(|e| ClientError::Json(e.to_string()))?;
let v = parsed.value();
let source: String = v
.to_member("source")
.and_then(|m| m.required())
.and_then(|v| v.try_into())
.map_err(|e| {
ClientError::Json(format!(
"volume configuration missing or invalid source: {e}"
))
})?;
let format: String = v
.to_member("format")
.and_then(|m| m.required())
.and_then(|v| v.try_into())
.map_err(|e| {
ClientError::Json(format!(
"volume configuration missing or invalid format: {e}"
))
})?;
Ok(VolumeResolution { source, format })
}
pub(crate) fn normalize_image_reference(image: &str) -> String {
match image.split_once('/') {
None => format!("docker.io/library/{image}"),
Some((first, _)) => {
if first.contains('.') || first.contains(':') || first == "localhost" {
image.to_string()
} else {
format!("docker.io/{image}")
}
}
}
}
fn match_image_descriptor(data: &[u8], image: &str) -> std::result::Result<String, ClientError> {
if data.is_empty() {
return Err(ClientError::ImageNotFound(image.to_string()));
}
let text = std::str::from_utf8(data)
.map_err(|e| ClientError::Json(format!("imageDescriptions is not UTF-8: {e}")))?;
let parsed = nojson::RawJson::parse(text).map_err(|e| ClientError::Json(e.to_string()))?;
let arr = parsed
.value()
.to_array()
.map_err(|e| ClientError::Json(e.to_string()))?;
let docker_ref = normalize_image_reference(image);
for item in arr {
let ref_str = xpc::member_opt_string(&item, "reference");
let matched = ref_str.as_deref() == Some(image) || ref_str.as_deref() == Some(&docker_ref);
if !matched {
continue;
}
match item.to_member("descriptor").and_then(|m| m.required()) {
Ok(desc) => return Ok(desc.extract().text().to_string()),
Err(_) => {
return Err(ClientError::Other(format!(
"image {image}: matched reference but descriptor is missing"
)));
}
}
}
Err(ClientError::ImageNotFound(image.to_string()))
}
fn create_pipe() -> Result<(std::fs::File, std::fs::File)> {
let mut fds = [-1i32; 2];
unsafe {
if libc::pipe(fds.as_mut_ptr()) != 0 {
return Err(ClientError::Other("failed to create pipe".into()).into());
}
for &fd in &fds {
if libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) == -1 {
let _ = libc::close(fds[0]);
let _ = libc::close(fds[1]);
return Err(ClientError::Other("failed to set FD_CLOEXEC on pipe".into()).into());
}
}
Ok((
std::fs::File::from_raw_fd(fds[0]),
std::fs::File::from_raw_fd(fds[1]),
))
}
}
fn close_valid_fds(fds: &[std::os::fd::RawFd]) {
for fd in fds {
if *fd >= 0 {
unsafe { libc::close(*fd) };
}
}
}
fn read_file_to_vec_cancellable(
mut f: std::fs::File,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> std::io::Result<Option<Vec<u8>>> {
use std::os::fd::AsRawFd;
use std::sync::atomic::Ordering;
const MAX_OUTPUT_SIZE: u64 = 64 * 1024 * 1024;
const POLL_TIMEOUT_MS: i32 = 100;
let fd = f.as_raw_fd();
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
loop {
if cancel.load(Ordering::Relaxed) {
return Ok(None);
}
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, POLL_TIMEOUT_MS) };
if ret < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(err);
}
if ret == 0 {
continue;
}
if pfd.revents & (libc::POLLERR | libc::POLLNVAL) != 0 {
return Err(std::io::Error::other(format!(
"poll on exec output pipe failed with revents {:#x}",
pfd.revents
)));
}
if pfd.revents & (libc::POLLIN | libc::POLLHUP) != 0 {
match f.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() as u64 > MAX_OUTPUT_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::OutOfMemory,
format!("output exceeds {MAX_OUTPUT_SIZE} bytes limit"),
));
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
}
Ok(Some(buf))
}
pub(crate) struct XpcExecResult {
pub(crate) exit_code: Option<i64>,
pub(crate) stdout: Vec<u8>,
pub(crate) stderr: Vec<u8>,
}
fn parse_published_ports(item: &nojson::RawJsonValue<'_, '_>) -> Ports {
let mut ports = Ports::default();
let config = match item
.to_member("configuration")
.ok()
.and_then(|m| m.optional())
{
Some(c) => c,
None => return ports,
};
let published_ports = match config
.to_member("publishedPorts")
.ok()
.and_then(|m| m.optional())
{
Some(p) => p,
None => return ports,
};
let arr = match published_ports.to_array() {
Ok(a) => a,
Err(_) => return ports,
};
for p in arr {
let container_port: u16 = p
.to_member("containerPort")
.ok()
.and_then(|m| m.optional())
.and_then(|v| TryInto::<u16>::try_into(v).ok())
.unwrap_or(0);
let host_port: u16 = p
.to_member("hostPort")
.ok()
.and_then(|m| m.optional())
.and_then(|v| TryInto::<u16>::try_into(v).ok())
.unwrap_or(0);
let proto: String = p
.to_member("proto")
.ok()
.and_then(|m| m.optional())
.and_then(|v| TryInto::<String>::try_into(v).ok())
.unwrap_or_else(|| "tcp".into());
let host_address: String = p
.to_member("hostAddress")
.ok()
.and_then(|m| m.optional())
.and_then(|v| TryInto::<String>::try_into(v).ok())
.unwrap_or_default();
let container_port = match proto.as_str() {
"udp" => ContainerPort::Udp(container_port),
"sctp" => ContainerPort::Sctp(container_port),
_ => ContainerPort::Tcp(container_port),
};
if host_port == 0 || container_port.as_u16() == 0 {
tracing::debug!(
"skipping published port with zero host_port or container_port: \
host_port={host_port}, container_port={container_port}"
);
continue;
}
if host_address.parse::<std::net::Ipv6Addr>().is_ok() {
ports.add_ipv6_mapping(container_port, host_port);
} else {
ports.add_mapping(container_port, host_port);
}
}
ports
}
struct StopOptions {
signal: String,
timeout: u64,
}
impl DisplayJson for StopOptions {
fn fmt(&self, f: &mut nojson::JsonFormatter) -> std::fmt::Result {
f.object(|f| {
f.member("signal", &self.signal)?;
f.member("timeoutInSeconds", self.timeout)
})
}
}
struct CreateOpts {
auto_remove: bool,
}
impl DisplayJson for CreateOpts {
fn fmt(&self, f: &mut nojson::JsonFormatter) -> std::fmt::Result {
f.object(|f| {
f.member("autoRemove", self.auto_remove)?;
f.member("rootFsOverride", &Option::<String>::None)
})
}
}
struct Platform {
os: String,
architecture: String,
}
impl DisplayJson for Platform {
fn fmt(&self, f: &mut nojson::JsonFormatter) -> std::fmt::Result {
f.object(|f| {
f.member("os", &self.os)?;
f.member("architecture", &self.architecture)
})
}
}
fn oci_platform_json(architecture: &str) -> Vec<u8> {
j(&Platform {
os: "linux".into(),
architecture: architecture.into(),
})
}
struct ProcCfg {
executable: String,
arguments: Vec<String>,
environment: Vec<String>,
working_directory: String,
}
impl DisplayJson for ProcCfg {
fn fmt(&self, f: &mut nojson::JsonFormatter) -> std::fmt::Result {
f.object(|f| {
f.member("executable", &self.executable)?;
f.member("arguments", &self.arguments)?;
f.member("environment", &self.environment)?;
f.member("workingDirectory", &self.working_directory)?;
f.member("terminal", false)?;
f.member("user", &UserId { uid: 0, gid: 0 })?;
f.member("supplementalGroups", Vec::<u32>::new())?;
f.member("rlimits", Vec::<u32>::new())
})
}
}
struct UserId {
uid: u32,
gid: u32,
}
impl DisplayJson for UserId {
fn fmt(&self, f: &mut nojson::JsonFormatter) -> std::fmt::Result {
f.object(|f| {
f.member(
"id",
&InnerId {
uid: self.uid,
gid: self.gid,
},
)
})
}
}
struct InnerId {
uid: u32,
gid: u32,
}
impl DisplayJson for InnerId {
fn fmt(&self, f: &mut nojson::JsonFormatter) -> std::fmt::Result {
f.object(|f| {
f.member("uid", self.uid)?;
f.member("gid", self.gid)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_tag_with_dot_still_gets_library_prefix() {
assert_eq!(
normalize_image_reference("alpine:3.19"),
"docker.io/library/alpine:3.19"
);
}
#[test]
fn normalize_image_reference_is_idempotent_for_samples() {
for image in [
"alpine:latest",
"user/repo:1",
"ghcr.io/org/app:tag",
"localhost/foo",
"registry.example.com:5000/ns/name:v1",
] {
let once = normalize_image_reference(image);
let twice = normalize_image_reference(&once);
assert_eq!(once, twice, "冪等であること: {image}");
}
}
#[test]
fn normalize_unqualified_gets_library_prefix() {
let normalized = normalize_image_reference("nginx:1.25");
assert!(
normalized.starts_with("docker.io/library/"),
"非修飾参照は docker.io/library/ で始まること: {normalized}"
);
assert!(
normalized.ends_with("nginx:1.25"),
"元の参照が末尾に残ること: {normalized}"
);
}
#[test]
fn xpc_timeout_for_grace_keeps_default_for_short_grace() {
assert_eq!(
xpc_timeout_for_grace(0),
crate::xpc::DEFAULT_TIMEOUT,
"グレース 0 秒は 60 秒のまま"
);
assert_eq!(
xpc_timeout_for_grace(30),
crate::xpc::DEFAULT_TIMEOUT,
"グレース 30 秒は 60 秒のまま (30 + 30 = 60)"
);
}
#[test]
fn xpc_timeout_for_grace_extends_over_default() {
assert_eq!(xpc_timeout_for_grace(61), Duration::from_secs(61 + 30));
assert_eq!(xpc_timeout_for_grace(120), Duration::from_secs(120 + 30));
}
#[test]
fn xpc_timeout_for_grace_saturates_at_long_timeout() {
assert_eq!(
xpc_timeout_for_grace(i32::MAX as u64),
crate::xpc::LONG_TIMEOUT
);
assert_eq!(
xpc_timeout_for_grace(crate::xpc::LONG_TIMEOUT.as_secs() - 20),
crate::xpc::LONG_TIMEOUT,
"グレース + 30 秒が 24 時間を超える場合は飽和"
);
assert_eq!(
xpc_timeout_for_grace(crate::xpc::LONG_TIMEOUT.as_secs() - 30),
crate::xpc::LONG_TIMEOUT,
"ちょうど 24 時間になる場合は飽和値"
);
assert_eq!(
xpc_timeout_for_grace(crate::xpc::LONG_TIMEOUT.as_secs() - 31),
Duration::from_secs(crate::xpc::LONG_TIMEOUT.as_secs() - 1),
"グレース + 30 秒が 24 時間未満の場合は飽和しない"
);
}
#[test]
fn xpc_timeout_for_grace_never_overflows() {
assert_eq!(xpc_timeout_for_grace(u64::MAX), crate::xpc::LONG_TIMEOUT);
}
#[test]
fn is_valid_volume_name_accepts_allowed_chars() {
for name in ["data", "Data-1", "a_b.c-d", "0", "a".repeat(255).as_str()] {
assert!(is_valid_volume_name(name), "有効な名前であること: {name}");
}
}
#[test]
fn is_valid_volume_name_rejects_invalid_names() {
for name in [
"",
"-data",
".data",
"_data",
"da ta",
"a/b",
"a#b",
"a".repeat(256).as_str(),
] {
assert!(!is_valid_volume_name(name), "無効な名前であること: {name}");
}
}
#[test]
fn is_volume_already_exists_error_matches_existing_volume_message() {
let err = crate::Error::Client(ClientError::Xpc(
"XPC error internalError: volume 'data' already exists".into(),
));
assert!(is_volume_already_exists_error(&err));
}
#[test]
fn is_volume_already_exists_error_rejects_other_errors() {
let xpc_err = crate::Error::Client(ClientError::Xpc(
"XPC error internalError: storage error".into(),
));
assert!(!is_volume_already_exists_error(&xpc_err));
let other_err = crate::Error::Client(ClientError::ContainerNotFound("id".into()));
assert!(!is_volume_already_exists_error(&other_err));
}
#[test]
fn parse_volume_configuration_extracts_source_and_format() {
let json = br#"{
"name": "data",
"driver": "local",
"format": "ext4",
"source": "/host/volumes/data/volume.img"
}"#;
let resolution = parse_volume_configuration(json).expect("パースに成功すること");
assert_eq!(resolution.source, "/host/volumes/data/volume.img");
assert_eq!(resolution.format, "ext4");
}
#[test]
fn parse_volume_configuration_rejects_missing_source() {
let json = br#"{"name":"data","format":"ext4"}"#;
let err = parse_volume_configuration(json).expect_err("source 欠落はエラーであること");
assert!(matches!(err, crate::Error::Client(ClientError::Json(_))));
}
#[test]
fn parse_volume_configuration_rejects_missing_format() {
let json = br#"{"name":"data","source":"/host/volumes/data/volume.img"}"#;
let err = parse_volume_configuration(json).expect_err("format 欠落はエラーであること");
assert!(matches!(err, crate::Error::Client(ClientError::Json(_))));
}
#[test]
fn parse_volume_configuration_rejects_invalid_json() {
let err =
parse_volume_configuration(b"not-json").expect_err("不正 JSON はエラーであること");
assert!(matches!(err, crate::Error::Client(ClientError::Json(_))));
}
#[test]
fn parse_volume_configuration_rejects_non_utf8() {
let err =
parse_volume_configuration(&[0xff, 0xfe]).expect_err("非 UTF-8 はエラーであること");
assert!(matches!(err, crate::Error::Client(ClientError::Json(_))));
}
#[test]
fn parse_published_ports_splits_ipv4_and_ipv6_by_host_address() {
let json = r#"{"configuration":{"publishedPorts":[
{"hostAddress":"0.0.0.0","hostPort":18080,"containerPort":80,"proto":"tcp"},
{"hostAddress":"::","hostPort":18081,"containerPort":81,"proto":"tcp"}
]}}"#;
let parsed = nojson::RawJson::parse(json).expect("テスト用 JSON の解析に成功すること");
let ports = parse_published_ports(&parsed.value());
assert_eq!(ports.map_to_host_port_ipv4(80u16), Some(18080));
assert_eq!(ports.map_to_host_port_ipv6(80u16), None);
assert_eq!(ports.map_to_host_port_ipv6(81u16), Some(18081));
assert_eq!(ports.map_to_host_port_ipv4(81u16), None);
}
#[test]
fn parse_published_ports_skips_zero_ports() {
let json = r#"{"configuration":{"publishedPorts":[
{"hostAddress":"0.0.0.0","hostPort":0,"containerPort":80,"proto":"tcp"},
{"hostAddress":"0.0.0.0","hostPort":18081,"containerPort":0,"proto":"tcp"},
{"hostAddress":"0.0.0.0","hostPort":18082,"containerPort":82,"proto":"tcp"}
]}}"#;
let parsed = nojson::RawJson::parse(json).expect("テスト用 JSON の解析に成功すること");
let ports = parse_published_ports(&parsed.value());
assert_eq!(ports.map_to_host_port_ipv4(80u16), None);
assert_eq!(ports.map_to_host_port_ipv4(0u16), None);
assert_eq!(ports.map_to_host_port_ipv4(82u16), Some(18082));
}
#[test]
fn oci_platform_json_contains_os_and_architecture() {
let amd64 = String::from_utf8(oci_platform_json("amd64")).expect("有効な UTF-8 であること");
assert_eq!(amd64, r#"{"os":"linux","architecture":"amd64"}"#);
let arm64 = String::from_utf8(oci_platform_json("arm64")).expect("有効な UTF-8 であること");
assert_eq!(arm64, r#"{"os":"linux","architecture":"arm64"}"#);
}
#[test]
fn match_image_descriptor_rejects_invalid_json() {
let err = match_image_descriptor(b"not-json", "alpine:latest").unwrap_err();
assert!(
matches!(err, ClientError::Json(_)),
"不正 JSON は ClientError::Json であること: {err:?}"
);
}
#[test]
fn match_image_descriptor_rejects_non_array_json() {
let err = match_image_descriptor(b"{}", "alpine:latest").unwrap_err();
assert!(
matches!(err, ClientError::Json(_)),
"非配列 JSON は ClientError::Json であること: {err:?}"
);
}
#[test]
fn match_image_descriptor_empty_or_empty_array_is_not_found() {
assert!(matches!(
match_image_descriptor(b"", "alpine:latest"),
Err(ClientError::ImageNotFound(_))
));
assert!(matches!(
match_image_descriptor(b"[]", "alpine:latest"),
Err(ClientError::ImageNotFound(_))
));
}
#[test]
fn match_image_descriptor_finds_normalized_reference() {
let json = br#"[{"reference":"docker.io/library/alpine:latest","descriptor":{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:dead","size":1}}]"#;
let desc = match_image_descriptor(json, "alpine:latest").expect("一致するはず");
assert!(
desc.contains("sha256:dead"),
"descriptor の digest が含まれること: {desc}"
);
}
#[test]
fn match_image_descriptor_missing_descriptor_is_other() {
let json = br#"[
{"reference":"docker.io/library/alpine:latest"},
{"reference":"docker.io/library/alpine:latest","descriptor":{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:later","size":1}}
]"#;
let err = match_image_descriptor(json, "alpine:latest").unwrap_err();
match err {
ClientError::Other(msg) => {
assert!(
msg.contains("descriptor is missing"),
"Other に欠落理由が含まれること: {msg}"
);
}
other => panic!("ClientError::Other を期待したが {other:?}"),
}
}
#[test]
fn match_image_descriptor_rejects_non_utf8() {
let err = match_image_descriptor(&[0xff, 0xfe], "alpine:latest").unwrap_err();
assert!(
matches!(err, ClientError::Json(_)),
"非 UTF-8 は ClientError::Json であること: {err:?}"
);
assert!(
err.to_string().contains("not UTF-8"),
"UTF-8 失敗であることが分かること: {err}"
);
}
#[test]
fn host_path_for_xpc_accepts_utf8() {
let s = host_path_for_xpc(std::path::Path::new("/tmp/hello")).expect("成功すること");
assert_eq!(s, "/tmp/hello");
}
#[test]
fn host_path_for_xpc_rejects_non_utf8() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let path = std::path::Path::new(OsStr::from_bytes(b"/tmp/\xff\xfe"));
let err = host_path_for_xpc(path).expect_err("非 UTF-8 はエラーであること");
let msg = err.to_string();
assert!(
msg.contains("not valid UTF-8"),
"UTF-8 失敗であることが分かること: {msg}"
);
}
#[test]
fn create_pipe_sets_fd_cloexec() {
use std::os::fd::AsRawFd;
let (r, w) = create_pipe().expect("pipe を作成できること");
for (name, fd) in [("read", r.as_raw_fd()), ("write", w.as_raw_fd())] {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
assert!(flags >= 0, "{name} の F_GETFD が成功すること");
assert!(
flags & libc::FD_CLOEXEC != 0,
"{name} に FD_CLOEXEC が立っていること: flags={flags}"
);
}
}
#[test]
fn cancellable_read_reads_small_file() {
use std::sync::atomic::AtomicBool;
let dir = std::env::temp_dir().join(format!(
"container-rs-read-file-test-{}",
crate::core::util::unique_suffix()
));
std::fs::create_dir(&dir).expect("一時ディレクトリの作成に失敗した");
let path = dir.join("small.txt");
std::fs::write(&path, b"hello").expect("ファイルの書き込みに失敗した");
let f = std::fs::File::open(&path).expect("ファイルを開けること");
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let result = read_file_to_vec_cancellable(f, cancel)
.expect("読み出しに成功すること")
.expect("キャンセルされていないため Some になること");
assert_eq!(result, b"hello", "ファイルの内容が一致すること");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cancellable_read_rejects_over_limit() {
use std::sync::atomic::AtomicBool;
let dir = std::env::temp_dir().join(format!(
"container-rs-read-file-limit-test-{}",
crate::core::util::unique_suffix()
));
std::fs::create_dir(&dir).expect("一時ディレクトリの作成に失敗した");
let path = dir.join("large.bin");
let size = 64 * 1024 * 1024 + 1;
{
let f = std::fs::File::create(&path).expect("ファイルの作成に失敗した");
f.set_len(size).expect("set_len に失敗した");
}
let f = std::fs::File::open(&path).expect("ファイルを開けること");
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let result = read_file_to_vec_cancellable(f, cancel);
assert!(result.is_err(), "64 MiB 超過はエラーであること");
let err = result.unwrap_err();
assert_eq!(
err.kind(),
std::io::ErrorKind::OutOfMemory,
"エラー種別が OutOfMemory であること: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cancellable_read_accepts_exactly_at_limit() {
use std::sync::atomic::AtomicBool;
let dir = std::env::temp_dir().join(format!(
"container-rs-read-file-boundary-test-{}",
crate::core::util::unique_suffix()
));
std::fs::create_dir(&dir).expect("一時ディレクトリの作成に失敗した");
let path = dir.join("exact.bin");
let size = 64 * 1024 * 1024;
{
let f = std::fs::File::create(&path).expect("ファイルの作成に失敗した");
f.set_len(size).expect("set_len に失敗した");
}
let f = std::fs::File::open(&path).expect("ファイルを開けること");
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let result = read_file_to_vec_cancellable(f, cancel);
assert!(
result.is_ok(),
"ちょうど 64 MiB は成功すること: {:?}",
result.err()
);
assert_eq!(
result
.expect("ちょうど 64 MiB は読み出せること")
.expect("キャンセルされていないため Some になること")
.len(),
size as usize,
"読み込みバイト数が 64 MiB であること"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cancellable_read_cancel_finishes_before_limit() {
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
let (out_read, out_write) = super::create_pipe().expect("pipe の作成に成功すること");
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let thread_cancel = cancel.clone();
let handle =
std::thread::spawn(move || read_file_to_vec_cancellable(out_read, thread_cancel));
std::thread::sleep(std::time::Duration::from_millis(100));
cancel.store(true, Ordering::Relaxed);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(handle.join());
});
let joined = rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("キャンセル後の読み取りスレッドが 2 秒以内に終了すること");
let result = joined
.expect("読み取りスレッドが panic しないこと")
.expect("read がエラーにならないこと");
assert_eq!(result, None, "キャンセル時は None が返ること");
drop(out_write);
}
#[test]
fn cancellable_read_reads_to_eof_without_cancel() {
use std::io::Write;
use std::sync::atomic::AtomicBool;
let (out_read, mut out_write) = super::create_pipe().expect("pipe の作成に成功すること");
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let handle = std::thread::spawn(move || read_file_to_vec_cancellable(out_read, cancel));
out_write
.write_all(b"hello")
.expect("書き込みに成功すること");
drop(out_write);
let result = handle
.join()
.expect("読み取りスレッドが panic しないこと")
.expect("read がエラーにならないこと");
assert_eq!(result, Some(b"hello".to_vec()), "EOF まで読み切ること");
}
#[test]
fn cancellable_read_does_not_timeout_without_cancel() {
use std::io::Write;
use std::sync::atomic::AtomicBool;
let (out_read, mut out_write) = super::create_pipe().expect("pipe の作成に成功すること");
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let handle = std::thread::spawn(move || read_file_to_vec_cancellable(out_read, cancel));
std::thread::sleep(std::time::Duration::from_millis(5100));
out_write
.write_all(b"late")
.expect("書き込みに成功すること");
drop(out_write);
let result = handle
.join()
.expect("読み取りスレッドが panic しないこと")
.expect("read がエラーにならないこと");
assert_eq!(
result,
Some(b"late".to_vec()),
"5 秒超の exec でも読み取りが打ち切られないこと"
);
}
}