use a3s_box_core::error::{BoxError, Result};
use oci_spec::image::{ImageConfiguration, ImageIndex, ImageManifest};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct OciHealthCheck {
pub test: Vec<String>,
pub interval: Option<u64>,
pub timeout: Option<u64>,
pub retries: Option<u32>,
pub start_period: Option<u64>,
}
#[derive(Debug)]
pub struct OciImage {
root_dir: PathBuf,
manifest_digest: String,
config: OciImageConfig,
layer_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct OciImageConfig {
pub entrypoint: Option<Vec<String>>,
pub cmd: Option<Vec<String>>,
pub env: Vec<(String, String)>,
pub working_dir: Option<String>,
pub user: Option<String>,
pub exposed_ports: Vec<String>,
pub labels: std::collections::HashMap<String, String>,
pub volumes: Vec<String>,
pub stop_signal: Option<String>,
pub health_check: Option<OciHealthCheck>,
pub onbuild: Vec<String>,
}
impl OciImage {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
let root_dir = path.as_ref().to_path_buf();
Self::validate_oci_layout(&root_dir)?;
let index = Self::load_index(&root_dir)?;
let manifest_digest = index
.manifests()
.first()
.ok_or_else(|| BoxError::OciImageError("No manifests in index.json".to_string()))?
.digest()
.to_string();
let manifest = Self::load_manifest(&root_dir, &manifest_digest)?;
let config_digest = manifest.config().digest().to_string();
let config = Self::load_config(&root_dir, &config_digest)?;
let layer_paths = manifest
.layers()
.iter()
.map(|layer| Self::blob_path(&root_dir, layer.digest()))
.collect();
Ok(Self {
root_dir,
manifest_digest,
config,
layer_paths,
})
}
pub fn config(&self) -> &OciImageConfig {
&self.config
}
pub fn layer_paths(&self) -> &[PathBuf] {
&self.layer_paths
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
pub fn manifest_digest(&self) -> &str {
&self.manifest_digest
}
pub fn entrypoint(&self) -> Option<&[String]> {
self.config.entrypoint.as_deref()
}
pub fn cmd(&self) -> Option<&[String]> {
self.config.cmd.as_deref()
}
pub fn env(&self) -> &[(String, String)] {
&self.config.env
}
pub fn working_dir(&self) -> Option<&str> {
self.config.working_dir.as_deref()
}
pub fn label(&self, key: &str) -> Option<&str> {
self.config.labels.get(key).map(|s| s.as_str())
}
fn validate_oci_layout(root_dir: &Path) -> Result<()> {
let oci_layout_path = root_dir.join("oci-layout");
if !oci_layout_path.exists() {
return Err(BoxError::OciImageError(format!(
"Not a valid OCI layout: missing oci-layout file in {}",
root_dir.display()
)));
}
let index_path = root_dir.join("index.json");
if !index_path.exists() {
return Err(BoxError::OciImageError(format!(
"Not a valid OCI layout: missing index.json in {}",
root_dir.display()
)));
}
let blobs_dir = root_dir.join("blobs");
if !blobs_dir.exists() {
return Err(BoxError::OciImageError(format!(
"Not a valid OCI layout: missing blobs directory in {}",
root_dir.display()
)));
}
Ok(())
}
fn load_index(root_dir: &Path) -> Result<ImageIndex> {
let index_path = root_dir.join("index.json");
let content = std::fs::read_to_string(&index_path).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to read index.json at {}: {}",
index_path.display(),
e
))
})?;
serde_json::from_str(&content)
.map_err(|e| BoxError::OciImageError(format!("Failed to parse index.json: {}", e)))
}
fn load_manifest(root_dir: &Path, digest: &str) -> Result<ImageManifest> {
let blob_path = Self::blob_path(root_dir, digest);
let content = std::fs::read_to_string(&blob_path).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to read manifest at {}: {}",
blob_path.display(),
e
))
})?;
serde_json::from_str(&content)
.map_err(|e| BoxError::OciImageError(format!("Failed to parse manifest: {}", e)))
}
fn load_config(root_dir: &Path, digest: &str) -> Result<OciImageConfig> {
let blob_path = Self::blob_path(root_dir, digest);
let content = std::fs::read_to_string(&blob_path).map_err(|e| {
BoxError::OciImageError(format!(
"Failed to read config at {}: {}",
blob_path.display(),
e
))
})?;
let oci_config: ImageConfiguration = serde_json::from_str(&content)
.map_err(|e| BoxError::OciImageError(format!("Failed to parse config: {}", e)))?;
let onbuild: Vec<String> = serde_json::from_str::<serde_json::Value>(&content)
.ok()
.and_then(|v| v.get("config").and_then(|c| c.get("OnBuild")).cloned())
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
Ok(OciImageConfig::from_oci_config(&oci_config, onbuild))
}
fn blob_path(root_dir: &Path, digest: &str) -> PathBuf {
let parts: Vec<&str> = digest.split(':').collect();
let (algorithm, hash) = if parts.len() == 2 {
(parts[0], parts[1])
} else {
("sha256", digest)
};
root_dir.join("blobs").join(algorithm).join(hash)
}
}
impl OciImageConfig {
fn from_oci_config(oci_config: &ImageConfiguration, onbuild: Vec<String>) -> Self {
let config = oci_config.config();
let entrypoint = config.as_ref().and_then(|c| c.entrypoint().clone());
let cmd = config.as_ref().and_then(|c| c.cmd().clone());
let working_dir = config.as_ref().and_then(|c| c.working_dir().clone());
let user = config.as_ref().and_then(|c| c.user().clone());
let env = config
.as_ref()
.and_then(|c| c.env().as_ref())
.map(|env_list| {
env_list
.iter()
.filter_map(|e| {
let parts: Vec<&str> = e.splitn(2, '=').collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
})
.collect()
})
.unwrap_or_default();
let exposed_ports = config
.as_ref()
.and_then(|c| c.exposed_ports().as_ref())
.map(|ports| ports.to_vec())
.unwrap_or_default();
let labels = config
.as_ref()
.and_then(|c| c.labels().clone())
.unwrap_or_default();
let volumes = config
.as_ref()
.and_then(|c| c.volumes().as_ref())
.map(|vols| vols.to_vec())
.unwrap_or_default();
let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
let health_check = None;
Self {
entrypoint,
cmd,
env,
working_dir,
user,
exposed_ports,
labels,
volumes,
stop_signal,
health_check,
onbuild,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_validate_oci_layout_missing_oci_layout_file() {
let temp_dir = TempDir::new().unwrap();
let result = OciImage::validate_oci_layout(temp_dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("oci-layout"));
}
#[test]
fn test_validate_oci_layout_missing_index_json() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("oci-layout"),
r#"{"imageLayoutVersion":"1.0.0"}"#,
)
.unwrap();
let result = OciImage::validate_oci_layout(temp_dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("index.json"));
}
#[test]
fn test_validate_oci_layout_missing_blobs() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("oci-layout"),
r#"{"imageLayoutVersion":"1.0.0"}"#,
)
.unwrap();
fs::write(temp_dir.path().join("index.json"), "{}").unwrap();
let result = OciImage::validate_oci_layout(temp_dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("blobs"));
}
#[test]
fn test_validate_oci_layout_valid() {
let temp_dir = TempDir::new().unwrap();
create_minimal_oci_layout(temp_dir.path());
let result = OciImage::validate_oci_layout(temp_dir.path());
assert!(result.is_ok());
}
#[test]
fn test_blob_path() {
let root = PathBuf::from("/images/test");
let path = OciImage::blob_path(&root, "sha256:abc123");
assert_eq!(path, PathBuf::from("/images/test/blobs/sha256/abc123"));
let path = OciImage::blob_path(&root, "abc123");
assert_eq!(path, PathBuf::from("/images/test/blobs/sha256/abc123"));
}
#[test]
fn test_from_path_valid_image() {
let temp_dir = TempDir::new().unwrap();
create_complete_oci_image(temp_dir.path());
let image = OciImage::from_path(temp_dir.path()).unwrap();
assert_eq!(image.entrypoint(), Some(&["/bin/agent".to_string()][..]));
assert_eq!(
image.cmd(),
Some(&["--port".to_string(), "8080".to_string()][..])
);
assert_eq!(image.working_dir(), Some("/workspace"));
let env = image.env();
assert!(env
.iter()
.any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
assert_eq!(image.label("a3s.type"), Some("agent"));
assert_eq!(image.layer_paths().len(), 1);
}
#[test]
fn test_from_path_exposes_manifest_digest() {
let temp_dir = TempDir::new().unwrap();
create_complete_oci_image(temp_dir.path());
let image = OciImage::from_path(temp_dir.path()).unwrap();
assert_eq!(image.manifest_digest(), "sha256:manifestxyz789");
}
#[test]
fn test_from_path_nonexistent() {
let result = OciImage::from_path("/nonexistent/path");
assert!(result.is_err());
}
fn create_minimal_oci_layout(path: &Path) {
fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
fs::write(path.join("index.json"), "{}").unwrap();
fs::create_dir_all(path.join("blobs/sha256")).unwrap();
}
fn create_complete_oci_image(path: &Path) {
fs::create_dir_all(path.join("blobs/sha256")).unwrap();
fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
let config_content = r#"{
"architecture": "amd64",
"os": "linux",
"config": {
"Entrypoint": ["/bin/agent"],
"Cmd": ["--port", "8080"],
"Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
"WorkingDir": "/workspace",
"Labels": {
"a3s.type": "agent",
"a3s.version": "1.0.0"
}
},
"rootfs": {
"type": "layers",
"diff_ids": ["sha256:layer1hash"]
},
"history": []
}"#;
let config_hash = "configabc123";
fs::write(path.join("blobs/sha256").join(config_hash), config_content).unwrap();
let layer_hash = "layerdef456";
create_test_layer(&path.join("blobs/sha256").join(layer_hash));
let manifest_content = format!(
r#"{{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {{
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:{}",
"size": {}
}},
"layers": [
{{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:{}",
"size": 100
}}
]
}}"#,
config_hash,
config_content.len(),
layer_hash
);
let manifest_hash = "manifestxyz789";
fs::write(
path.join("blobs/sha256").join(manifest_hash),
&manifest_content,
)
.unwrap();
let index_content = format!(
r#"{{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:{}",
"size": {}
}}
]
}}"#,
manifest_hash,
manifest_content.len()
);
fs::write(path.join("index.json"), index_content).unwrap();
}
#[test]
fn test_from_oci_config_parses_volumes() {
let config_json = r#"{
"architecture": "amd64",
"os": "linux",
"config": {
"Volumes": {
"/data": {},
"/var/log": {}
}
},
"rootfs": {
"type": "layers",
"diff_ids": []
},
"history": []
}"#;
let oci_config: oci_spec::image::ImageConfiguration =
serde_json::from_str(config_json).unwrap();
let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
assert_eq!(config.volumes.len(), 2);
assert!(config.volumes.contains(&"/data".to_string()));
assert!(config.volumes.contains(&"/var/log".to_string()));
}
#[test]
fn test_from_oci_config_no_volumes() {
let config_json = r#"{
"architecture": "amd64",
"os": "linux",
"config": {},
"rootfs": {
"type": "layers",
"diff_ids": []
},
"history": []
}"#;
let oci_config: oci_spec::image::ImageConfiguration =
serde_json::from_str(config_json).unwrap();
let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
assert!(config.volumes.is_empty());
}
#[test]
fn test_load_config_parses_onbuild_triggers() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir_all(temp_dir.path().join("blobs/sha256")).unwrap();
fs::write(
temp_dir.path().join("oci-layout"),
r#"{"imageLayoutVersion":"1.0.0"}"#,
)
.unwrap();
let config_content = r#"{
"architecture": "amd64",
"os": "linux",
"config": {
"OnBuild": ["RUN echo hello", "COPY . /app"]
},
"rootfs": {"type": "layers", "diff_ids": []},
"history": []
}"#;
let config_hash = "onbuildcfg001";
fs::write(
temp_dir.path().join("blobs/sha256").join(config_hash),
config_content,
)
.unwrap();
let manifest_content = format!(
r#"{{"schemaVersion":2,"config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:{}","size":{}}},"layers":[]}}"#,
config_hash,
config_content.len()
);
let manifest_hash = "onbuildmfst001";
fs::write(
temp_dir.path().join("blobs/sha256").join(manifest_hash),
&manifest_content,
)
.unwrap();
let index_content = format!(
r#"{{"schemaVersion":2,"manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:{}","size":{}}}]}}"#,
manifest_hash,
manifest_content.len()
);
fs::write(temp_dir.path().join("index.json"), index_content).unwrap();
let image = OciImage::from_path(temp_dir.path()).unwrap();
assert_eq!(
image.config().onbuild,
vec!["RUN echo hello", "COPY . /app"]
);
}
fn create_test_layer(path: &Path) {
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
let file = fs::File::create(path).unwrap();
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_size(5);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "test.txt", b"hello" as &[u8])
.unwrap();
builder.finish().unwrap();
}
}