use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
fn pelagos_group_gid() -> Option<libc::gid_t> {
let name = std::ffi::CString::new("pelagos").ok()?;
let gr = unsafe { libc::getgrnam(name.as_ptr()) };
if gr.is_null() {
None
} else {
Some(unsafe { (*gr).gr_gid })
}
}
fn ensure_image_dirs() -> io::Result<()> {
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
let pelagos_gid = pelagos_group_gid();
let mode = if pelagos_gid.is_some() { 0o775 } else { 0o755 };
for dir in [
crate::paths::layers_dir(),
crate::paths::images_dir(),
crate::paths::build_cache_dir(),
crate::paths::blobs_dir(),
] {
if !dir.exists() {
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(mode))?;
if let Some(gid) = pelagos_gid {
let path_cstr = std::ffi::CString::new(dir.as_os_str().as_bytes())
.map_err(|e| io::Error::other(e.to_string()))?;
unsafe { libc::lchown(path_cstr.as_ptr(), u32::MAX as libc::uid_t, gid) };
}
}
}
}
Ok(())
}
fn default_health_interval() -> u64 {
30
}
fn default_health_timeout() -> u64 {
10
}
fn default_health_retries() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthConfig {
pub cmd: Vec<String>,
#[serde(default = "default_health_interval")]
pub interval_secs: u64,
#[serde(default = "default_health_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub start_period_secs: u64,
#[serde(default = "default_health_retries")]
pub retries: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImageConfig {
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default)]
pub entrypoint: Vec<String>,
#[serde(default)]
pub working_dir: String,
#[serde(default)]
pub user: String,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub healthcheck: Option<HealthConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageManifest {
pub reference: String,
pub digest: String,
pub layers: Vec<String>,
pub config: ImageConfig,
}
pub fn reference_to_dirname(reference: &str) -> String {
reference.replace([':', '/', '@'], "_")
}
pub fn image_dir(reference: &str) -> PathBuf {
crate::paths::images_dir().join(reference_to_dirname(reference))
}
pub fn layer_dir(digest: &str) -> PathBuf {
let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
crate::paths::layers_dir().join(hex)
}
pub fn layer_exists(digest: &str) -> bool {
layer_dir(digest).is_dir()
}
pub fn blob_path(digest: &str) -> std::path::PathBuf {
crate::paths::blob_path(digest)
}
pub fn blob_exists(digest: &str) -> bool {
crate::paths::blob_path(digest).exists()
}
pub fn save_blob(digest: &str, data: &[u8]) -> io::Result<()> {
let path = crate::paths::blob_path(digest);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, data)
}
pub fn load_blob(digest: &str) -> io::Result<Vec<u8>> {
std::fs::read(crate::paths::blob_path(digest))
}
pub fn save_blob_diffid(blob_digest: &str, diff_id: &str) -> io::Result<()> {
std::fs::write(crate::paths::blob_diffid_path(blob_digest), diff_id)
}
pub fn load_blob_diffid(blob_digest: &str) -> Option<String> {
std::fs::read_to_string(crate::paths::blob_diffid_path(blob_digest)).ok()
}
pub fn oci_config_path(reference: &str) -> std::path::PathBuf {
image_dir(reference).join("oci-config.json")
}
pub fn save_oci_config(reference: &str, config_json: &str) -> io::Result<()> {
std::fs::write(oci_config_path(reference), config_json)
}
pub fn load_oci_config(reference: &str) -> io::Result<String> {
std::fs::read_to_string(oci_config_path(reference))
}
pub fn extract_layer(digest: &str, tar_gz_path: &Path) -> io::Result<PathBuf> {
ensure_image_dirs()?;
let rootless = crate::paths::is_rootless();
let dest = layer_dir(digest);
if dest.is_dir() {
return Ok(dest);
}
let partial = dest.with_extension("partial");
if partial.exists() {
std::fs::remove_dir_all(&partial)?;
}
std::fs::create_dir_all(&partial)?;
let file = std::fs::File::open(tar_gz_path)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.set_preserve_permissions(true);
archive.set_overwrite(true);
for entry in archive.entries()? {
let mut entry = entry?;
let raw_path = entry.path()?.into_owned();
let file_name = raw_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if file_name == ".wh..wh..opq" {
let parent = partial.join(raw_path.parent().unwrap_or(Path::new("")));
std::fs::create_dir_all(&parent)?;
if rootless {
let _ = set_opaque_xattr_userxattr(&parent);
} else {
let _ = set_opaque_xattr(&parent);
}
continue;
}
if let Some(target_name) = file_name.strip_prefix(".wh.") {
let parent = partial.join(raw_path.parent().unwrap_or(Path::new("")));
std::fs::create_dir_all(&parent)?;
let whiteout_path = parent.join(target_name);
if rootless {
create_whiteout_userxattr(&whiteout_path)?;
} else {
create_whiteout_device(&whiteout_path)?;
}
continue;
}
entry.unpack_in(&partial)?;
}
std::fs::create_dir_all(dest.parent().unwrap())?;
std::fs::rename(&partial, &dest)?;
Ok(dest)
}
fn create_whiteout_device(path: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes())
.map_err(|_| io::Error::other("invalid path for whiteout device"))?;
let dev = libc::makedev(0, 0);
let ret = unsafe { libc::mknod(c_path.as_ptr(), libc::S_IFCHR | 0o666, dev) };
if ret != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn set_opaque_xattr(dir: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(dir.as_os_str().as_bytes())
.map_err(|_| io::Error::other("invalid path for xattr"))?;
let name = b"trusted.overlay.opaque\0";
let value = b"y";
let ret = unsafe {
libc::setxattr(
c_path.as_ptr(),
name.as_ptr() as *const libc::c_char,
value.as_ptr() as *const libc::c_void,
value.len(),
0,
)
};
if ret != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn create_whiteout_userxattr(path: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
std::fs::File::create(path)?;
let c_path = CString::new(path.as_os_str().as_bytes())
.map_err(|_| io::Error::other("invalid path for whiteout xattr"))?;
let name = b"user.overlay.whiteout\0";
let value = b"y";
let ret = unsafe {
libc::setxattr(
c_path.as_ptr(),
name.as_ptr() as *const libc::c_char,
value.as_ptr() as *const libc::c_void,
value.len(),
0,
)
};
if ret != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn set_opaque_xattr_userxattr(dir: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(dir.as_os_str().as_bytes())
.map_err(|_| io::Error::other("invalid path for xattr"))?;
let name = b"user.overlay.opaque\0";
let value = b"y";
let ret = unsafe {
libc::setxattr(
c_path.as_ptr(),
name.as_ptr() as *const libc::c_char,
value.as_ptr() as *const libc::c_void,
value.len(),
0,
)
};
if ret != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub fn save_image(manifest: &ImageManifest) -> io::Result<()> {
ensure_image_dirs()?;
let dir = image_dir(&manifest.reference);
std::fs::create_dir_all(&dir)?;
let json =
serde_json::to_string_pretty(manifest).map_err(|e| io::Error::other(e.to_string()))?;
std::fs::write(dir.join("manifest.json"), json)
}
pub fn load_image(reference: &str) -> io::Result<ImageManifest> {
let path = image_dir(reference).join("manifest.json");
let data = std::fs::read_to_string(&path)?;
serde_json::from_str(&data).map_err(|e| io::Error::other(e.to_string()))
}
pub fn list_images() -> Vec<ImageManifest> {
let dir = crate::paths::images_dir();
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut manifests = Vec::new();
for entry in entries.flatten() {
let manifest_path = entry.path().join("manifest.json");
if let Ok(data) = std::fs::read_to_string(&manifest_path) {
if let Ok(m) = serde_json::from_str::<ImageManifest>(&data) {
manifests.push(m);
}
}
}
manifests
}
pub fn remove_image(reference: &str) -> io::Result<()> {
let dir = image_dir(reference);
if dir.is_dir() {
std::fs::remove_dir_all(&dir)
} else {
Err(io::Error::other(format!("image '{}' not found", reference)))
}
}
pub fn layer_dirs(manifest: &ImageManifest) -> Vec<PathBuf> {
let mut seen = std::collections::HashSet::new();
manifest
.layers
.iter()
.rev()
.map(|d| layer_dir(d))
.filter(|p| seen.insert(p.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_blob_path_strips_prefix() {
let p = blob_path("sha256:deadbeef");
assert_eq!(p, crate::paths::blobs_dir().join("deadbeef.tar.gz"));
}
#[test]
fn test_blob_exists_false_for_missing() {
assert!(!blob_exists(
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
));
}
#[test]
fn test_reference_to_dirname() {
assert_eq!(reference_to_dirname("alpine:latest"), "alpine_latest");
assert_eq!(
reference_to_dirname("docker.io/library/alpine:3.19"),
"docker.io_library_alpine_3.19"
);
assert_eq!(
reference_to_dirname("registry.example.com/foo/bar:v1"),
"registry.example.com_foo_bar_v1"
);
}
#[test]
fn test_layer_dir_strips_prefix() {
let d = layer_dir("sha256:abc123def456");
assert_eq!(d, crate::paths::layers_dir().join("abc123def456"));
}
#[test]
fn test_layer_dir_no_prefix() {
let d = layer_dir("abc123def456");
assert_eq!(d, crate::paths::layers_dir().join("abc123def456"));
}
#[test]
fn test_manifest_roundtrip() {
let manifest = ImageManifest {
reference: "test:latest".to_string(),
digest: "sha256:000".to_string(),
layers: vec!["sha256:aaa".to_string(), "sha256:bbb".to_string()],
config: ImageConfig {
env: vec!["PATH=/usr/bin".to_string()],
cmd: vec!["/bin/sh".to_string()],
entrypoint: Vec::new(),
working_dir: String::new(),
user: String::new(),
labels: HashMap::new(),
healthcheck: None,
},
};
let json = serde_json::to_string(&manifest).unwrap();
let loaded: ImageManifest = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.reference, "test:latest");
assert_eq!(loaded.layers.len(), 2);
assert_eq!(loaded.config.cmd, vec!["/bin/sh"]);
}
#[test]
fn test_layer_dirs_order() {
let manifest = ImageManifest {
reference: "test:latest".to_string(),
digest: "sha256:000".to_string(),
layers: vec!["sha256:bottom".to_string(), "sha256:top".to_string()],
config: ImageConfig {
env: Vec::new(),
cmd: Vec::new(),
entrypoint: Vec::new(),
working_dir: String::new(),
user: String::new(),
labels: HashMap::new(),
healthcheck: None,
},
};
let dirs = layer_dirs(&manifest);
assert_eq!(dirs[0], crate::paths::layers_dir().join("top"));
assert_eq!(dirs[1], crate::paths::layers_dir().join("bottom"));
}
}