use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use anyhow::{Context, Result, bail}; use lazaret_core::configuration::ConfinedPathPlan; use oci_client::Reference; use oci_client::client::{Client, ClientConfig}; use oci_client::errors::{OciDistributionError, OciErrorCode}; use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest}; use oci_client::secrets::RegistryAuth; use rustix::fs::{ AtFlags, Mode, OFlags, RenameFlags, ResolveFlags, fstat, mkdirat, openat2, renameat_with, unlinkat, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{PreparedLowerError, PreparedLowerResult}; const OCI_LAYER_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; const MAX_CONFIG_BYTES: usize = 16 * 1024 * 1024; const MAX_LAYER_BYTES: u64 = 32 * 1024 * 1024 * 1024; #[derive(Clone, Debug, Eq, PartialEq)] pub struct Platform { pub os: String, pub architecture: String, pub variant: Option, } impl Platform { #[must_use] pub fn linux(architecture: impl Into) -> Self { Self { os: "linux".to_owned(), architecture: architecture.into(), variant: None, } } } #[derive(Clone, Eq, PartialEq)] pub enum RegistryCredentials { Anonymous, Basic { username: String, password: String }, Bearer(String), } impl std::fmt::Debug for RegistryCredentials { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Anonymous => formatter.write_str("Anonymous"), Self::Basic { username, .. } => formatter .debug_struct("Basic") .field("username", username) .field("password", &"[redacted]") .finish(), Self::Bearer(_) => formatter .debug_tuple("Bearer") .field(&"[redacted]") .finish(), } } } #[derive(Debug)] pub struct TransportedImage { pub layout_path: PathBuf, pub source_identity: Vec, pub selected_manifest_digest: [u8; 32], retained_layout: Option, } #[derive(Debug)] pub(crate) struct RetainedLayout { root: Arc, parent: Arc, name: String, identity: (u64, u64), cleanup_armed: bool, } impl RetainedLayout { pub(crate) fn root(&self) -> &File { &self.root } } impl TransportedImage { pub(crate) fn retained_layout(&self) -> Option<&RetainedLayout> { self.retained_layout.as_ref() } /// Deletes only the exact descriptor-retained transport staging leaf. /// /// # Errors /// Refuses a replacement path and reports any descriptor-relative cleanup failure. pub fn cleanup(mut self) -> PreparedLowerResult<()> { self.cleanup_inner() .map_err(PreparedLowerError::publication) } fn cleanup_inner(&mut self) -> Result<()> { self.retained_layout .as_mut() .map_or(Ok(()), cleanup_retained_layout) } } impl Drop for RetainedLayout { fn drop(&mut self) { let _ = cleanup_retained_layout(self); } } #[derive(Clone, Debug, Default)] pub struct OciRegistryTransport; impl OciRegistryTransport { /// Pulls one explicitly selected platform into a digest-verified private OCI layout. /// /// # Errors /// /// Refuses invalid references, authentication failures, unsupported platforms, malformed /// descriptors, resource limits, and transport or filesystem errors. pub async fn pull( &self, reference: &str, credentials: &RegistryCredentials, platform: &Platform, layout_path: &Path, ) -> PreparedLowerResult { self.pull_with_config( reference, credentials, platform, layout_path, ClientConfig::default(), ) .await } /// Pulls an image into an exact descriptor-retained child of Storage Root. /// /// # Errors /// Refuses changed Storage Root authority, fixed-child symlinks, staging collisions, /// authentication failures, corrupt content, and unsupported images. pub async fn pull_confined( &self, reference: &str, credentials: &RegistryCredentials, platform: &Platform, storage_root: &ConfinedPathPlan, staging_id: [u8; 32], ) -> PreparedLowerResult { self.pull_to( reference, credentials, platform, LayoutDestination::Confined { storage_root, staging_id, }, ClientConfig::default(), ) .await } async fn pull_with_config( &self, reference: &str, credentials: &RegistryCredentials, platform: &Platform, layout_path: &Path, config: ClientConfig, ) -> PreparedLowerResult { self.pull_to( reference, credentials, platform, LayoutDestination::Ambient(layout_path), config, ) .await } async fn pull_to( &self, reference: &str, credentials: &RegistryCredentials, platform: &Platform, destination: LayoutDestination<'_>, mut config: ClientConfig, ) -> PreparedLowerResult { validate_credentials(credentials).map_err(PreparedLowerError::authentication)?; let reference = Reference::from_str(reference) .context("invalid OCI image reference") .map_err(PreparedLowerError::transport)?; let selected_platform = platform.clone(); config.max_concurrent_download = 1; config.platform_resolver = Some(Box::new(move |entries| { entries .iter() .find(|entry| { entry.platform.as_ref().is_some_and(|candidate| { candidate.os.to_string() == selected_platform.os && candidate.architecture.to_string() == selected_platform.architecture && candidate.variant == selected_platform.variant }) }) .map(|entry| entry.digest.clone()) })); let client = Client::new(config); let auth = match credentials { RegistryCredentials::Anonymous => RegistryAuth::Anonymous, RegistryCredentials::Basic { username, password } => { RegistryAuth::Basic(username.clone(), password.clone()) } RegistryCredentials::Bearer(token) => RegistryAuth::Bearer(token.clone()), }; let (manifest, manifest_digest, config_json) = client .pull_manifest_and_config(&reference, &auth) .await .map_err(|error| { distribution_error(error, "pulling selected OCI manifest and config") })?; if config_json.len() > MAX_CONFIG_BYTES { return Err(PreparedLowerError::manifest_selection(anyhow::anyhow!( "OCI config exceeds {MAX_CONFIG_BYTES} bytes" ))); } verify_platform(&config_json, platform).map_err(PreparedLowerError::platform_selection)?; validate_manifest(&manifest).map_err(PreparedLowerError::manifest_selection)?; let layout = LayoutWriter::create(destination) .context("creating private OCI layout") .map_err(PreparedLowerError::confinement)?; layout .write_verified_blob(&manifest.config.digest, config_json.as_bytes()) .context("writing verified OCI config") .map_err(PreparedLowerError::publication)?; pull_layers(&client, &reference, &manifest, &layout).await?; let local_manifest = serde_json::to_vec(&manifest).map_err(PreparedLowerError::manifest_selection)?; let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest))); layout .write_verified_blob(&local_digest, &local_manifest) .context("writing verified OCI manifest") .map_err(PreparedLowerError::publication)?; layout .write_layout_index(&local_digest, local_manifest.len()) .context("writing OCI layout index") .map_err(PreparedLowerError::publication)?; let selected_manifest_digest = parse_sha256(&local_digest).map_err(PreparedLowerError::manifest_selection)?; let source_identity = format!( "oci-v1\0{}\0{}\0{manifest_digest}", reference.registry(), reference.repository() ) .into_bytes(); Ok(TransportedImage { layout_path: layout.label().to_owned(), source_identity, selected_manifest_digest, retained_layout: layout.into_retained(), }) } } async fn pull_layers( client: &Client, reference: &Reference, manifest: &OciImageManifest, layout: &LayoutWriter, ) -> PreparedLowerResult<()> { for descriptor in &manifest.layers { let size = u64::try_from(descriptor.size) .context("negative OCI layer size") .map_err(PreparedLowerError::manifest_selection)?; if size > MAX_LAYER_BYTES { return Err(PreparedLowerError::layer_validation(anyhow::anyhow!( "OCI layer exceeds {MAX_LAYER_BYTES} bytes" ))); } let digest_component = digest_component(&descriptor.digest).map_err(PreparedLowerError::manifest_selection)?; if layout .blob_exists(digest_component) .map_err(PreparedLowerError::operating_system)? { layout .require_existing_blob(&descriptor.digest, size) .map_err(PreparedLowerError::layer_validation)?; continue; } let (file, temporary) = layout .create_blob(digest_component) .with_context(|| format!("creating OCI layer {}", descriptor.digest)) .map_err(PreparedLowerError::materialization)?; let file = tokio::fs::File::from_std(file); if let Err(error) = client.pull_blob(reference, descriptor, file).await { layout.remove_temporary_blob(&temporary); return Err(distribution_error( error, format!("pulling OCI layer {}", descriptor.digest), )); } let observed = layout .temporary_blob_size(&temporary) .with_context(|| format!("measuring OCI layer {}", descriptor.digest)) .map_err(PreparedLowerError::operating_system)?; if observed != size { return Err(PreparedLowerError::layer_validation(anyhow::anyhow!( "OCI layer {} size mismatch", descriptor.digest ))); } layout .publish_blob(&temporary, digest_component) .with_context(|| format!("publishing OCI layer {}", descriptor.digest)) .map_err(PreparedLowerError::publication)?; } Ok(()) } fn distribution_error( error: OciDistributionError, context: impl Into, ) -> PreparedLowerError { let authentication = matches!( &error, OciDistributionError::AuthenticationFailure(_) | OciDistributionError::RegistryTokenDecodeError(_) | OciDistributionError::UnauthorizedError { .. } | OciDistributionError::ServerError { code: 401 | 403, .. } ) || matches!( &error, OciDistributionError::RegistryError { envelope, .. } if envelope.errors.iter().any(|error| { matches!(error.code, OciErrorCode::Unauthorized | OciErrorCode::Denied) }) ) || matches!( &error, OciDistributionError::RequestError(error) if error.status().is_some_and(|status| matches!(status.as_u16(), 401 | 403)) ); let platform = matches!( &error, OciDistributionError::ImageIndexParsingNoPlatformResolverError ); let manifest = matches!( &error, OciDistributionError::ConfigConversionError(_) | OciDistributionError::ImageManifestNotFoundError(_) | OciDistributionError::IncompatibleLayerMediaTypeError(_) | OciDistributionError::JsonError(_) | OciDistributionError::ManifestEncodingError(_) | OciDistributionError::ManifestParsingError(_) | OciDistributionError::PullNoLayersError | OciDistributionError::UnsupportedMediaTypeError(_) | OciDistributionError::UnsupportedSchemaVersionError(_) | OciDistributionError::VersionedParsingError(_) ); let source = anyhow::Error::new(error).context(context.into()); if authentication { PreparedLowerError::authentication(source) } else if platform { PreparedLowerError::platform_selection(source) } else if manifest { PreparedLowerError::manifest_selection(source) } else { PreparedLowerError::transport(source) } } #[derive(Deserialize)] struct ImageConfigPlatform { os: String, architecture: String, #[serde(default)] variant: Option, } fn verify_platform(config: &str, expected: &Platform) -> Result<()> { let observed: ImageConfigPlatform = serde_json::from_str(config)?; if observed.os != expected.os || observed.architecture != expected.architecture || observed.variant != expected.variant { bail!( "selected image platform {}/{} does not match requested {}/{}", observed.os, observed.architecture, expected.os, expected.architecture ); } Ok(()) } fn validate_credentials(credentials: &RegistryCredentials) -> Result<()> { match credentials { RegistryCredentials::Anonymous => Ok(()), RegistryCredentials::Basic { username, password } if !username.is_empty() && !password.is_empty() => { Ok(()) } RegistryCredentials::Bearer(token) if !token.is_empty() => Ok(()), RegistryCredentials::Basic { .. } | RegistryCredentials::Bearer(_) => { bail!("registry credentials are incomplete") } } } fn validate_manifest(manifest: &OciImageManifest) -> Result<()> { if manifest.schema_version != 2 || manifest.layers.len() > 256 { bail!("unsupported OCI manifest schema or layer count"); } for descriptor in &manifest.layers { if !matches!( descriptor.media_type.as_str(), IMAGE_LAYER_MEDIA_TYPE | IMAGE_LAYER_GZIP_MEDIA_TYPE | OCI_LAYER_ZSTD ) { bail!("unsupported OCI layer media type {}", descriptor.media_type); } digest_component(&descriptor.digest)?; u64::try_from(descriptor.size).context("negative OCI layer size")?; } digest_component(&manifest.config.digest)?; Ok(()) } #[derive(Clone, Copy)] enum LayoutDestination<'a> { Ambient(&'a Path), Confined { storage_root: &'a ConfinedPathPlan, staging_id: [u8; 32], }, } enum LayoutWriter { Ambient(PathBuf), Confined { layout: RetainedLayout, label: PathBuf, }, } impl LayoutWriter { fn create(destination: LayoutDestination<'_>) -> Result { match destination { LayoutDestination::Ambient(path) => { create_layout(path)?; Ok(Self::Ambient(path.to_owned())) } LayoutDestination::Confined { storage_root, staging_id, } => { let storage = File::from(storage_root.open_authority()?); let parent = Arc::new(create_directory(&storage, "image-transports")?); let name = hex(&staging_id); mkdirat(&parent, name.as_str(), Mode::from_raw_mode(0o700))?; let root = Arc::new(open_directory(&parent, Path::new(&name))?); require_owned_directory(&root)?; let stat = fstat(&root)?; let blobs = create_directory(&root, "blobs")?; drop(create_directory(&blobs, "sha256")?); write_relative_atomic( &root, Path::new("oci-layout"), b"{\"imageLayoutVersion\":\"1.0.0\"}\n", )?; let label = PathBuf::from(std::ffi::OsString::from_vec( storage_root.canonical_label().as_bytes().to_vec(), )) .join("image-transports") .join(&name); Ok(Self::Confined { layout: RetainedLayout { root, parent, name, identity: (stat.st_dev, stat.st_ino), cleanup_armed: true, }, label, }) } } } fn label(&self) -> &Path { match self { Self::Ambient(path) => path, Self::Confined { label, .. } => label, } } fn into_retained(self) -> Option { match self { Self::Ambient(_) => None, Self::Confined { layout, .. } => Some(layout), } } fn write_verified_blob(&self, digest: &str, bytes: &[u8]) -> Result<()> { if parse_sha256(digest)? != Sha256::digest(bytes).as_slice() { bail!("OCI descriptor digest mismatch for {digest}"); } let component = digest_component(digest)?; if self .blob_exists(component) .context("checking verified OCI blob destination")? { return self.require_existing_blob(digest, bytes.len() as u64); } let (mut file, temporary) = self .create_blob(component) .context("creating verified OCI blob")?; file.write_all(bytes).context("writing verified OCI blob")?; file.sync_all().context("syncing verified OCI blob")?; self.publish_blob(&temporary, component) .context("publishing verified OCI blob") } fn blob_exists(&self, component: &str) -> Result { match self { Self::Ambient(path) => Ok(path.join("blobs/sha256").join(component).exists()), Self::Confined { layout, .. } => match open_relative_file( layout.root(), &Path::new("blobs/sha256").join(component), OFlags::RDONLY, ) { Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(error.into()), }, } } fn require_existing_blob(&self, digest: &str, size: u64) -> Result<()> { let mut file = self.open_blob(digest_component(digest)?)?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?; if bytes.len() as u64 != size || parse_sha256(digest)? != Sha256::digest(&bytes).as_slice() { bail!("existing OCI blob does not match its descriptor"); } Ok(()) } fn open_blob(&self, component: &str) -> Result { match self { Self::Ambient(path) => Ok(File::open(path.join("blobs/sha256").join(component))?), Self::Confined { layout, .. } => Ok(open_relative_file( layout.root(), &Path::new("blobs/sha256").join(component), OFlags::RDONLY, )?), } } fn create_blob(&self, component: &str) -> Result<(File, String)> { let temporary = format!(".{component}.partial"); match self { Self::Ambient(path) => Ok(( OpenOptions::new() .create_new(true) .write(true) .open(path.join("blobs/sha256").join(&temporary))?, temporary, )), Self::Confined { layout, .. } => Ok(( open_relative_file( layout.root(), &Path::new("blobs/sha256").join(&temporary), OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY, )?, temporary, )), } } fn temporary_blob_size(&self, temporary: &str) -> Result { Ok(fstat(&self.open_blob(temporary)?)?.st_size.cast_unsigned()) } fn publish_blob(&self, temporary: &str, component: &str) -> Result<()> { match self { Self::Ambient(path) => { std::fs::rename( path.join("blobs/sha256").join(temporary), path.join("blobs/sha256").join(component), )?; } Self::Confined { layout, .. } => { let blobs = open_directory(layout.root(), Path::new("blobs/sha256"))?; renameat_with(&blobs, temporary, &blobs, component, RenameFlags::NOREPLACE)?; blobs.sync_all()?; } } Ok(()) } fn remove_temporary_blob(&self, temporary: &str) { match self { Self::Ambient(path) => { let _ = std::fs::remove_file(path.join("blobs/sha256").join(temporary)); } Self::Confined { layout, .. } => { let _ = unlinkat( layout.root(), Path::new("blobs/sha256").join(temporary), AtFlags::empty(), ); } } } fn write_layout_index(&self, digest: &str, size: usize) -> Result<()> { let bytes = layout_index(digest, size)?; match self { Self::Ambient(path) => std::fs::write(path.join("index.json"), bytes)?, Self::Confined { layout, .. } => { write_relative_atomic(layout.root(), Path::new("index.json"), &bytes)?; } } Ok(()) } } fn create_directory(parent: &File, path: &str) -> Result { match mkdirat(parent, path, Mode::from_raw_mode(0o700)) { Ok(()) | Err(rustix::io::Errno::EXIST) => {} Err(error) => return Err(error.into()), } let directory = open_directory(parent, Path::new(path))?; require_owned_directory(&directory)?; Ok(directory) } fn require_owned_directory(directory: &File) -> Result<()> { let stat = fstat(directory)?; if stat.st_uid != rustix::process::getuid().as_raw() || stat.st_mode & 0o7777 != 0o700 { bail!("fixed image-transport directory must be owned by the invoking user with mode 0700"); } Ok(()) } fn open_directory(parent: &File, path: &Path) -> std::io::Result { openat2( parent, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), confined_resolve(), ) .map(File::from) .map_err(Into::into) } fn open_relative_file(parent: &File, path: &Path, flags: OFlags) -> std::io::Result { let mode = if flags.contains(OFlags::CREATE) { Mode::from_raw_mode(0o600) } else { Mode::empty() }; openat2( parent, path, flags | OFlags::NOFOLLOW | OFlags::CLOEXEC, mode, confined_resolve(), ) .map(File::from) .map_err(Into::into) } fn write_relative_atomic(root: &File, path: &Path, bytes: &[u8]) -> Result<()> { let name = path.file_name().context("layout file has no name")?; let parent_path = path.parent().unwrap_or(Path::new(".")); let parent = if parent_path.as_os_str().is_empty() { File::from(rustix::io::dup(root)?) } else { open_directory(root, parent_path)? }; let temporary = format!(".{}.partial", name.to_string_lossy()); let mut file = open_relative_file( &parent, Path::new(&temporary), OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY, )?; file.write_all(bytes)?; file.sync_all()?; renameat_with(&parent, temporary, &parent, name, RenameFlags::NOREPLACE)?; parent.sync_all()?; Ok(()) } fn confined_resolve() -> ResolveFlags { ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_XDEV } fn cleanup_retained_layout(layout: &mut RetainedLayout) -> Result<()> { if !layout.cleanup_armed { return Ok(()); } let current = open_directory(&layout.parent, Path::new(&layout.name))?; let stat = fstat(¤t)?; if (stat.st_dev, stat.st_ino) != layout.identity { bail!("transport staging path no longer names the retained layout"); } remove_layout_children(&layout.root)?; unlinkat(&layout.parent, layout.name.as_str(), AtFlags::REMOVEDIR)?; layout.cleanup_armed = false; Ok(()) } fn remove_layout_children(directory: &File) -> Result<()> { let mut buffer = [std::mem::MaybeUninit::::uninit(); 4096]; let mut entries = rustix::fs::RawDir::new(rustix::io::dup(directory)?, &mut buffer); while let Some(entry) = entries.next() { let entry = entry?; let name = entry.file_name(); if name.to_bytes() == b"." || name.to_bytes() == b".." { continue; } if entry.file_type() == rustix::fs::FileType::Directory { let child = open_directory( directory, Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())), )?; remove_layout_children(&child)?; unlinkat(directory, name, AtFlags::REMOVEDIR)?; } else { unlinkat(directory, name, AtFlags::empty())?; } } Ok(()) } fn create_layout(path: &Path) -> Result<()> { std::fs::create_dir(path).context("creating private OCI layout")?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; std::fs::create_dir_all(path.join("blobs/sha256"))?; std::fs::set_permissions(path.join("blobs"), std::fs::Permissions::from_mode(0o700))?; std::fs::set_permissions( path.join("blobs/sha256"), std::fs::Permissions::from_mode(0o700), )?; std::fs::write( path.join("oci-layout"), b"{\"imageLayoutVersion\":\"1.0.0\"}\n", )?; Ok(()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LayoutIndex<'a> { schema_version: u8, manifests: [LayoutDescriptor<'a>; 1], } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LayoutDescriptor<'a> { media_type: &'a str, digest: &'a str, size: usize, } fn layout_index(digest: &str, size: usize) -> Result> { let index = LayoutIndex { schema_version: 2, manifests: [LayoutDescriptor { media_type: "application/vnd.oci.image.manifest.v1+json", digest, size, }], }; Ok(serde_json::to_vec(&index)?) } fn digest_component(digest: &str) -> Result<&str> { let component = digest .strip_prefix("sha256:") .context("only sha256 OCI descriptors are supported")?; if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) { bail!("invalid sha256 OCI descriptor"); } Ok(component) } fn parse_sha256(digest: &str) -> Result<[u8; 32]> { let component = digest_component(digest)?; let mut bytes = [0; 32]; for (index, pair) in component.as_bytes().chunks_exact(2).enumerate() { bytes[index] = u8::from_str_radix(std::str::from_utf8(pair)?, 16)?; } Ok(bytes) } fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; bytes.iter().fold( String::with_capacity(bytes.len() * 2), |mut output, byte| { write!(output, "{byte:02x}").expect("writing to String cannot fail"); output }, ) } include!("transport/tests.rs");