use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use bollard::models::{ContainerCreateBody, ImageInspect};
use bollard::query_parameters::RemoveContainerOptionsBuilder;
use bollard::Docker;
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
use crate::error::{SandboxRuntimeError, SandlockError};
fn default_cache_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(".cache/sandlock/images")
}
pub async fn extract(image_ref: &str, cache_dir: Option<&Path>) -> Result<PathBuf, SandlockError> {
let docker = connect().await?;
let info = inspect(&docker, image_ref).await?;
let id = info.id.ok_or_else(|| {
SandboxRuntimeError::Child(format!("Docker returned no id for image {image_ref}"))
})?;
let cache = cache_dir.map(PathBuf::from).unwrap_or_else(default_cache_dir);
let dest = cache.join(sanitize_id(&id));
let rootfs = dest.join("rootfs");
if rootfs.is_dir() && dest.join(".complete").is_file() {
return Ok(rootfs);
}
let _ = fs::remove_dir_all(&dest);
fs::create_dir_all(&rootfs).map_err(SandboxRuntimeError::Io)?;
let body = ContainerCreateBody {
image: Some(image_ref.to_string()),
..Default::default()
};
let created = docker
.create_container(None, body)
.await
.map_err(|e| SandboxRuntimeError::Child(format!("docker create failed: {e}")))?;
let cid = created.id;
let tar_path = dest.join("export.tar");
let export_res = stream_export(&docker, &cid, &tar_path).await;
let _ = docker
.remove_container(
&cid,
Some(RemoveContainerOptionsBuilder::new().force(true).build()),
)
.await;
export_res?;
let rootfs_out = rootfs.clone();
let tar_in = tar_path.clone();
tokio::task::spawn_blocking(move || unpack_rootfs(&tar_in, &rootfs_out))
.await
.map_err(|e| SandboxRuntimeError::Child(format!("image unpack task failed: {e}")))??;
let _ = fs::remove_file(&tar_path);
fs::write(dest.join(".complete"), b"").map_err(SandboxRuntimeError::Io)?;
Ok(rootfs)
}
pub async fn inspect_cmd(image_ref: &str) -> Result<Vec<String>, SandlockError> {
let docker = connect().await?;
let info = inspect(&docker, image_ref).await?;
Ok(default_cmd(&info))
}
async fn connect() -> Result<Docker, SandlockError> {
let docker = Docker::connect_with_local_defaults().map_err(daemon_unreachable)?;
docker.ping().await.map_err(daemon_unreachable)?;
Ok(docker)
}
fn daemon_unreachable(e: bollard::errors::Error) -> SandlockError {
SandboxRuntimeError::Child(format!(
"cannot reach the Docker daemon, required for --image \
(is dockerd running and the socket accessible?): {e}"
))
.into()
}
async fn inspect(docker: &Docker, image_ref: &str) -> Result<ImageInspect, SandlockError> {
docker.inspect_image(image_ref).await.map_err(|e| {
SandboxRuntimeError::Child(format!(
"image not found in local Docker storage: {image_ref} ({e})"
))
.into()
})
}
async fn stream_export(docker: &Docker, cid: &str, tar_path: &Path) -> Result<(), SandlockError> {
let mut file = tokio::fs::File::create(tar_path)
.await
.map_err(SandboxRuntimeError::Io)?;
let mut stream = docker.export_container(cid);
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| SandboxRuntimeError::Child(format!("docker export failed: {e}")))?;
file.write_all(&chunk).await.map_err(SandboxRuntimeError::Io)?;
}
file.flush().await.map_err(SandboxRuntimeError::Io)?;
Ok(())
}
fn default_cmd(info: &ImageInspect) -> Vec<String> {
let cfg = info.config.as_ref();
let entrypoint = cfg.and_then(|c| c.entrypoint.clone()).unwrap_or_default();
let cmd = cfg.and_then(|c| c.cmd.clone()).unwrap_or_default();
let combined: Vec<String> = entrypoint.into_iter().chain(cmd).collect();
if combined.is_empty() {
vec!["/bin/sh".into()]
} else {
combined
}
}
fn sanitize_id(id: &str) -> String {
id.split_once(':').map(|(_, h)| h).unwrap_or(id).to_string()
}
fn unpack_rootfs(tar_path: &Path, rootfs: &Path) -> Result<(), SandlockError> {
let mut deferred_hardlinks: Vec<PathBuf> = Vec::new();
{
let file = fs::File::open(tar_path).map_err(SandboxRuntimeError::Io)?;
let mut archive = tar::Archive::new(file);
archive.set_preserve_permissions(true);
archive.set_preserve_mtime(true);
archive.set_overwrite(true);
for entry in archive.entries().map_err(SandboxRuntimeError::Io)? {
let mut entry = entry.map_err(SandboxRuntimeError::Io)?;
let raw = entry.path().map_err(SandboxRuntimeError::Io)?.into_owned();
let dest = rootfs.join(&raw);
if !dest.starts_with(rootfs) {
continue;
}
if entry.header().entry_type() == tar::EntryType::Link {
deferred_hardlinks.push(raw);
continue;
}
entry.unpack(&dest).map_err(SandboxRuntimeError::Io)?;
}
}
while !deferred_hardlinks.is_empty() {
let mut remaining: Vec<PathBuf> = Vec::new();
let mut applied_this_round = 0usize;
let target_set: HashSet<PathBuf> = deferred_hardlinks.iter().cloned().collect();
let file = fs::File::open(tar_path).map_err(SandboxRuntimeError::Io)?;
let mut archive = tar::Archive::new(file);
for entry in archive.entries().map_err(SandboxRuntimeError::Io)? {
let entry = entry.map_err(SandboxRuntimeError::Io)?;
if entry.header().entry_type() != tar::EntryType::Link {
continue;
}
let raw = entry.path().map_err(SandboxRuntimeError::Io)?.into_owned();
if !target_set.contains(&raw) {
continue;
}
let link_target = match entry.link_name().map_err(SandboxRuntimeError::Io)? {
Some(t) => t.into_owned(),
None => {
remaining.push(raw);
continue;
}
};
let src = rootfs.join(&link_target);
let dest = rootfs.join(&raw);
if !src.starts_with(rootfs) || !dest.starts_with(rootfs) {
continue;
}
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
}
if dest.exists() || dest.is_symlink() {
let _ = fs::remove_file(&dest);
}
match fs::hard_link(&src, &dest) {
Ok(_) => applied_this_round += 1,
Err(_) => remaining.push(raw),
}
}
if applied_this_round == 0 {
return Err(SandboxRuntimeError::Child(format!(
"image export has {} unresolved hard link(s); broken export",
remaining.len(),
))
.into());
}
deferred_hardlinks = remaining;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use bollard::models::ImageConfig;
fn write_tar(entries: impl FnOnce(&mut tar::Builder<Vec<u8>>)) -> (tempfile::TempDir, PathBuf) {
let mut builder = tar::Builder::new(Vec::new());
entries(&mut builder);
let bytes = builder.into_inner().unwrap();
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("export.tar");
fs::write(&p, bytes).unwrap();
(tmp, p)
}
fn append_file(b: &mut tar::Builder<Vec<u8>>, path: &str, data: &[u8]) {
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(data.len() as u64);
h.set_mode(0o644);
h.set_cksum();
b.append(&h, data).unwrap();
}
fn append_dir(b: &mut tar::Builder<Vec<u8>>, path: &str) {
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(0);
h.set_mode(0o755);
h.set_entry_type(tar::EntryType::Directory);
h.set_cksum();
b.append(&h, std::io::empty()).unwrap();
}
#[test]
fn unpack_writes_regular_files() {
let (_tmp, tar_path) = write_tar(|b| {
append_file(b, "greeting.txt", b"hello sandlock");
});
let rootfs_tmp = tempfile::tempdir().unwrap();
let rootfs = rootfs_tmp.path();
unpack_rootfs(&tar_path, rootfs).unwrap();
let greeting = rootfs.join("greeting.txt");
assert!(greeting.is_file());
assert_eq!(fs::read_to_string(&greeting).unwrap(), "hello sandlock");
}
#[test]
fn unpack_resolves_hardlinks_with_forward_references() {
let (_tmp, tar_path) = write_tar(|b| {
append_dir(b, "usr/");
append_dir(b, "usr/bin/");
let mut h = tar::Header::new_gnu();
h.set_path("usr/bin/perl5.34.0").unwrap();
h.set_size(0);
h.set_mode(0o755);
h.set_entry_type(tar::EntryType::Link);
h.set_link_name("usr/bin/perl").unwrap();
h.set_cksum();
b.append(&h, std::io::empty()).unwrap();
append_file(b, "usr/bin/perl", b"#!perl\nnop");
});
let rootfs_tmp = tempfile::tempdir().unwrap();
let rootfs = rootfs_tmp.path();
unpack_rootfs(&tar_path, rootfs).unwrap();
let perl = rootfs.join("usr/bin/perl");
let perl_versioned = rootfs.join("usr/bin/perl5.34.0");
assert!(perl.is_file(), "perl should exist as a regular file");
assert!(perl_versioned.is_file(), "perl5.34.0 should exist as a hard link");
use std::os::unix::fs::MetadataExt;
assert_eq!(
fs::metadata(&perl).unwrap().ino(),
fs::metadata(&perl_versioned).unwrap().ino(),
"hard link should share inode with target",
);
}
#[test]
fn default_cmd_combines_entrypoint_and_cmd() {
let info = ImageInspect {
config: Some(ImageConfig {
entrypoint: Some(vec!["/bin/sh".into(), "-c".into()]),
cmd: Some(vec!["echo hi".into()]),
..Default::default()
}),
..Default::default()
};
assert_eq!(default_cmd(&info), vec!["/bin/sh", "-c", "echo hi"]);
}
#[test]
fn default_cmd_falls_back_to_bin_sh() {
let info = ImageInspect {
config: Some(ImageConfig::default()),
..Default::default()
};
assert_eq!(default_cmd(&info), vec!["/bin/sh"]);
}
#[test]
fn sanitize_id_strips_algorithm_prefix() {
assert_eq!(sanitize_id("sha256:abcdef0123"), "abcdef0123");
assert_eq!(sanitize_id("abcdef0123"), "abcdef0123");
}
}