use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use tokio::io::AsyncWriteExt as _;
use crate::error::SandboxError;
const DOCKER_PREFIX: &str = "docker:";
const LAYOUT_STAGING_DIR: &str = "/var/lib/arcbox/sandbox/templates";
const OCI_LAYOUT_MARKER: &str = "oci-layout";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Template {
Default,
DockerImage(String),
}
impl Template {
pub(super) fn parse(reference: &str) -> Result<Self, SandboxError> {
let reference = reference.trim();
if reference.is_empty() {
return Ok(Self::Default);
}
if let Some(image) = reference.strip_prefix(DOCKER_PREFIX) {
if image.is_empty() {
return Err(SandboxError::InvalidArgument(
"template 'docker:' is missing an image reference".into(),
));
}
return Ok(Self::DockerImage(image.to_owned()));
}
Err(SandboxError::InvalidArgument(format!(
"unknown template {reference:?}; expected \"\" (built-in) or \"docker:<image>\""
)))
}
}
pub(super) async fn export_docker_image(image_ref: &str) -> Result<String> {
let key = image_content_key(image_ref).await?;
let dest = PathBuf::from(LAYOUT_STAGING_DIR).join(format!("layers-{key}"));
if dest.join(OCI_LAYOUT_MARKER).is_file() {
tracing::info!(image = image_ref, path = %dest.display(), "using cached image layout");
return Ok(dest.to_string_lossy().into_owned());
}
tokio::fs::create_dir_all(LAYOUT_STAGING_DIR)
.await
.with_context(|| format!("failed to create {LAYOUT_STAGING_DIR}"))?;
let unique = uuid::Uuid::new_v4();
let archive = PathBuf::from(LAYOUT_STAGING_DIR).join(format!(".save-{unique}.tar"));
let staged = PathBuf::from(LAYOUT_STAGING_DIR).join(format!(".layout-{unique}"));
let result = stage_layout(image_ref, &archive, &staged).await;
let _ = tokio::fs::remove_file(&archive).await;
if let Err(e) = result {
let _ = tokio::fs::remove_dir_all(&staged).await;
return Err(e);
}
match tokio::fs::rename(&staged, &dest).await {
Ok(()) => {}
Err(_) if dest.join(OCI_LAYOUT_MARKER).is_file() => {
let _ = tokio::fs::remove_dir_all(&staged).await;
}
Err(e) => {
let _ = tokio::fs::remove_dir_all(&staged).await;
return Err(e).with_context(|| format!("failed to publish layout {}", dest.display()));
}
}
tracing::info!(image = image_ref, path = %dest.display(), "exported image layout");
Ok(dest.to_string_lossy().into_owned())
}
async fn stage_layout(image_ref: &str, archive: &Path, staged: &Path) -> Result<()> {
let mut file = tokio::fs::File::create(archive)
.await
.with_context(|| format!("failed to create {}", archive.display()))?;
docker::get_image_export(image_ref, &mut file)
.await
.with_context(|| format!("failed to export image {image_ref} from the guest dockerd"))?;
file.flush().await.context("failed to flush image export")?;
drop(file);
tokio::fs::create_dir_all(staged)
.await
.with_context(|| format!("failed to create {}", staged.display()))?;
let archive = archive.to_owned();
let staged_dir = staged.to_owned();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&archive)
.with_context(|| format!("failed to open {}", archive.display()))?;
tar::Archive::new(file)
.unpack(&staged_dir)
.context("failed to unpack the image export")?;
Ok(())
})
.await
.context("image unpack task panicked")??;
if !staged.join(OCI_LAYOUT_MARKER).is_file() {
bail!(
"image export is not an OCI image layout (no {OCI_LAYOUT_MARKER}); \
the guest dockerd must use the containerd image store"
);
}
Ok(())
}
async fn image_content_key(image_ref: &str) -> Result<String> {
let inspect = docker::inspect_image(image_ref).await?;
let layers = inspect
.get("RootFS")
.and_then(|fs| fs.get("Layers"))
.and_then(|l| l.as_array())
.filter(|layers| !layers.is_empty())
.with_context(|| format!("docker reported no layers for image {image_ref}"))?;
let joined = layers
.iter()
.filter_map(|l| l.as_str())
.collect::<Vec<_>>()
.join(",");
if joined.is_empty() {
bail!("docker reported unreadable layer digests for image {image_ref}");
}
use sha2::Digest as _;
let digest = sha2::Sha256::digest(joined.as_bytes());
use std::fmt::Write as _;
Ok(digest.iter().take(16).fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
}))
}
mod docker {
use anyhow::{Context, Result, bail};
use arcbox_constants::paths::DOCKER_API_UNIX_SOCKET;
use http_body_util::{BodyExt as _, Empty};
use hyper::body::Bytes;
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncWrite, AsyncWriteExt as _};
use tokio::net::UnixStream;
const MAX_INSPECT_BYTES: usize = 4 * 1024 * 1024;
pub(super) async fn inspect_image(image_ref: &str) -> Result<serde_json::Value> {
let path = format!("/images/{}/json", urlencode(image_ref));
let mut response = get(&path).await?;
let status = response.status();
if !status.is_success() {
bail!("docker image inspect {image_ref} returned HTTP {status}");
}
let mut body = Vec::new();
while let Some(frame) = response.frame().await {
let frame = frame.context("failed to read the docker inspect response")?;
if let Some(chunk) = frame.data_ref() {
if body.len() + chunk.len() > MAX_INSPECT_BYTES {
bail!("docker inspect response exceeds {MAX_INSPECT_BYTES} bytes");
}
body.extend_from_slice(chunk);
}
}
serde_json::from_slice(&body).context("failed to parse the docker inspect response")
}
pub(super) async fn get_image_export<W>(image_ref: &str, sink: &mut W) -> Result<()>
where
W: AsyncWrite + Unpin,
{
let path = format!(
"/images/{}/get?platform={}",
urlencode(image_ref),
urlencode(&platform_json()?)
);
let mut response = get(&path).await?;
let status = response.status();
if !status.is_success() {
bail!("docker image export {image_ref} returned HTTP {status}");
}
while let Some(frame) = response.frame().await {
let frame = frame.context("failed to stream the docker image export")?;
if let Some(chunk) = frame.data_ref() {
sink.write_all(chunk)
.await
.context("failed to write the docker image export")?;
}
}
Ok(())
}
async fn get(path: &str) -> Result<hyper::Response<hyper::body::Incoming>> {
let stream = UnixStream::connect(DOCKER_API_UNIX_SOCKET)
.await
.with_context(|| format!("failed to connect {DOCKER_API_UNIX_SOCKET}"))?;
let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
.await
.context("docker HTTP handshake failed")?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::debug!(error = %e, "docker connection closed");
}
});
let request = hyper::Request::builder()
.method(hyper::Method::GET)
.uri(path)
.header(hyper::header::HOST, "localhost")
.body(Empty::<Bytes>::new())
.context("failed to build the docker request")?;
sender
.send_request(request)
.await
.context("failed to send the docker request")
}
fn platform_json() -> Result<String> {
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "amd64",
other => bail!("unsupported guest architecture for sandbox images: {other}"),
};
Ok(format!(r#"{{"architecture":"{arch}","os":"linux"}}"#))
}
fn urlencode(value: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
other => {
let _ = write!(out, "%{other:02X}");
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::{platform_json, urlencode};
#[test]
fn platform_json_is_the_guest_platform() {
let platform = platform_json().expect("supported test architecture");
assert!(platform.contains(r#""os":"linux""#), "{platform}");
assert!(
platform.contains(r#""architecture":"arm64""#)
|| platform.contains(r#""architecture":"amd64""#),
"{platform}"
);
assert!(!platform.contains("aarch64"), "{platform}");
}
#[test]
fn urlencode_escapes_registry_refs() {
assert_eq!(urlencode("alpine"), "alpine");
assert_eq!(urlencode("alpine:3.20"), "alpine%3A3.20");
assert_eq!(
urlencode("ghcr.io/org/img:tag"),
"ghcr.io%2Forg%2Fimg%3Atag"
);
assert_eq!(urlencode("img@sha256:ab"), "img%40sha256%3Aab");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_the_built_in_and_docker_forms() {
assert_eq!(Template::parse("").unwrap(), Template::Default);
assert_eq!(Template::parse(" ").unwrap(), Template::Default);
assert_eq!(
Template::parse("docker:alpine:3.20").unwrap(),
Template::DockerImage("alpine:3.20".into())
);
}
#[test]
fn parse_rejects_unknown_forms_instead_of_guessing() {
assert!(matches!(
Template::parse("alpine"),
Err(SandboxError::InvalidArgument(_))
));
assert!(matches!(
Template::parse("/var/lib/arcbox/rootfs.ext4"),
Err(SandboxError::InvalidArgument(_))
));
assert!(matches!(
Template::parse("docker:"),
Err(SandboxError::InvalidArgument(_))
));
}
}