This crate can parse and extract an OCI image bundled as a tar archive. Signed-off-by: Filip Schauer <f.scha...@proxmox.com> --- Cargo.toml | 1 + proxmox-oci/Cargo.toml | 21 ++++ proxmox-oci/debian/changelog | 5 + proxmox-oci/debian/control | 45 ++++++++ proxmox-oci/debian/debcargo.toml | 7 ++ proxmox-oci/src/lib.rs | 165 +++++++++++++++++++++++++++++ proxmox-oci/src/oci_tar_image.rs | 173 +++++++++++++++++++++++++++++++ 7 files changed, 417 insertions(+) create mode 100644 proxmox-oci/Cargo.toml create mode 100644 proxmox-oci/debian/changelog create mode 100644 proxmox-oci/debian/control create mode 100644 proxmox-oci/debian/debcargo.toml create mode 100644 proxmox-oci/src/lib.rs create mode 100644 proxmox-oci/src/oci_tar_image.rs
diff --git a/Cargo.toml b/Cargo.toml index 71763c5a..6f20ab18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "proxmox-metrics", "proxmox-network-api", "proxmox-notify", + "proxmox-oci", "proxmox-openid", "proxmox-product-config", "proxmox-rest-server", diff --git a/proxmox-oci/Cargo.toml b/proxmox-oci/Cargo.toml new file mode 100644 index 00000000..77545b03 --- /dev/null +++ b/proxmox-oci/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "proxmox-oci" +description = "OCI image parsing and extraction" +version = "0.1.0" + +authors.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +flate2.workspace = true +oci-spec = "0.8.1" +sha2 = "0.10" +tar.workspace = true +zstd.workspace = true + +proxmox-io.workspace = true diff --git a/proxmox-oci/debian/changelog b/proxmox-oci/debian/changelog new file mode 100644 index 00000000..754d06c1 --- /dev/null +++ b/proxmox-oci/debian/changelog @@ -0,0 +1,5 @@ +rust-proxmox-oci (0.1.0-1) bookworm; urgency=medium + + * Initial release. + + -- Proxmox Support Team <supp...@proxmox.com> Mon, 28 Apr 2025 12:34:56 +0200 diff --git a/proxmox-oci/debian/control b/proxmox-oci/debian/control new file mode 100644 index 00000000..b2317c8e --- /dev/null +++ b/proxmox-oci/debian/control @@ -0,0 +1,45 @@ +Source: rust-proxmox-oci +Section: rust +Priority: optional +Build-Depends: debhelper-compat (= 13), + dh-sequence-cargo +Build-Depends-Arch: cargo:native <!nocheck>, + rustc:native (>= 1.82) <!nocheck>, + libstd-rust-dev <!nocheck>, + librust-flate2-1+default-dev <!nocheck>, + librust-oci-spec-0.8+default-dev (>= 0.8.1-~~) <!nocheck>, + librust-proxmox-io-1+default-dev (>= 1.1.0-~~) <!nocheck>, + librust-sha2-0.10+default-dev <!nocheck>, + librust-tar-0.4+default-dev <!nocheck>, + librust-zstd-0.12+bindgen-dev <!nocheck>, + librust-zstd-0.12+default-dev <!nocheck> +Maintainer: Proxmox Support Team <supp...@proxmox.com> +Standards-Version: 4.7.0 +Vcs-Git: git://git.proxmox.com/git/proxmox.git +Vcs-Browser: https://git.proxmox.com/?p=proxmox.git +Homepage: https://proxmox.com +X-Cargo-Crate: proxmox-oci +Rules-Requires-Root: no + +Package: librust-proxmox-oci-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-flate2-1+default-dev, + librust-oci-spec-0.8+default-dev (>= 0.8.1-~~), + librust-proxmox-io-1+default-dev (>= 1.1.0-~~), + librust-sha2-0.10+default-dev, + librust-tar-0.4+default-dev, + librust-zstd-0.12+bindgen-dev, + librust-zstd-0.12+default-dev +Provides: + librust-proxmox-oci+default-dev (= ${binary:Version}), + librust-proxmox-oci-0-dev (= ${binary:Version}), + librust-proxmox-oci-0+default-dev (= ${binary:Version}), + librust-proxmox-oci-0.1-dev (= ${binary:Version}), + librust-proxmox-oci-0.1+default-dev (= ${binary:Version}), + librust-proxmox-oci-0.1.0-dev (= ${binary:Version}), + librust-proxmox-oci-0.1.0+default-dev (= ${binary:Version}) +Description: OCI image parsing and extraction - Rust source code + Source code for Debianized Rust crate "proxmox-oci" diff --git a/proxmox-oci/debian/debcargo.toml b/proxmox-oci/debian/debcargo.toml new file mode 100644 index 00000000..b7864cdb --- /dev/null +++ b/proxmox-oci/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team <supp...@proxmox.com>" + +[source] +vcs_git = "git://git.proxmox.com/git/proxmox.git" +vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-oci/src/lib.rs b/proxmox-oci/src/lib.rs new file mode 100644 index 00000000..57bd48b4 --- /dev/null +++ b/proxmox-oci/src/lib.rs @@ -0,0 +1,165 @@ +use flate2::read::GzDecoder; +use oci_spec::image::{Arch, Config, ImageConfiguration, ImageManifest, MediaType}; +use oci_spec::OciSpecError; +use oci_tar_image::OciTarImage; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs::File; +use std::io::{Read, Seek}; +use std::path::PathBuf; +use std::str::FromStr; +use tar::Archive; + +pub mod oci_tar_image; + +/// Build a mapping from uncompressed layer digests (as found in the image config's `rootfs.diff_ids`) +/// to their corresponding compressed-layer digests (i.e. the filenames under `blobs/<algorithm>/<digest>`) +fn build_layer_map<R: Read + Seek>( + oci_tar_image: &mut OciTarImage<R>, + image_manifest: &ImageManifest, +) -> HashMap<oci_spec::image::Digest, oci_spec::image::Descriptor> { + let mut layer_mapping = HashMap::new(); + + for layer in image_manifest.layers() { + match layer.media_type() { + MediaType::ImageLayer | MediaType::ImageLayerNonDistributable => { + layer_mapping.insert(layer.digest().clone(), layer.clone()); + } + MediaType::ImageLayerGzip + | MediaType::ImageLayerNonDistributableGzip + | MediaType::ImageLayerZstd + | MediaType::ImageLayerNonDistributableZstd => { + let compressed_blob = oci_tar_image.open_blob(layer.digest()).unwrap(); + let mut decoder: Box<dyn Read> = match layer.media_type() { + MediaType::ImageLayerGzip | MediaType::ImageLayerNonDistributableGzip => { + Box::new(GzDecoder::new(compressed_blob)) + } + MediaType::ImageLayerZstd | MediaType::ImageLayerNonDistributableZstd => { + Box::new(zstd::Decoder::new(compressed_blob).unwrap()) + } + _ => unreachable!(), + }; + let mut hasher = Sha256::new(); + let mut buf = proxmox_io::boxed::zeroed(4096); + + loop { + let bytes_read = decoder.read(&mut buf).unwrap(); + if bytes_read == 0 { + break; + } + + hasher.update(&buf[..bytes_read]); + } + + let uncompressed_digest = + oci_spec::image::Sha256Digest::from_str(&format!("{:x}", hasher.finalize())) + .unwrap(); + + layer_mapping.insert(uncompressed_digest.into(), layer.clone()); + } + _ => (), + } + } + + layer_mapping +} + +pub enum ProxmoxOciError { + NotAnOciImage, + OciSpecError(OciSpecError), +} + +impl From<OciSpecError> for ProxmoxOciError { + fn from(oci_spec_err: OciSpecError) -> Self { + Self::OciSpecError(oci_spec_err) + } +} + +impl From<std::io::Error> for ProxmoxOciError { + fn from(io_err: std::io::Error) -> Self { + Self::OciSpecError(io_err.into()) + } +} + +pub fn parse_oci_image( + oci_tar_path: &str, + rootfs_path: &str, +) -> Result<Option<Config>, ProxmoxOciError> { + let oci_tar_file = File::open(oci_tar_path)?; + let mut oci_tar_image = match OciTarImage::new(oci_tar_file) { + Ok(oci_tar_image) => oci_tar_image, + Err(_) => return Err(ProxmoxOciError::NotAnOciImage), + }; + let image_manifest = oci_tar_image.image_manifest(&Arch::Amd64)?; + + let image_config_descriptor = image_manifest.config(); + + if image_config_descriptor.media_type() != &MediaType::ImageConfig { + return Err(ProxmoxOciError::OciSpecError(OciSpecError::Other( + "ImageConfig has wrong media type".into(), + ))); + } + + let image_config_file = oci_tar_image.open_blob(image_config_descriptor.digest())?; + let image_config = ImageConfiguration::from_reader(image_config_file)?; + + extract_oci_image_rootfs( + &mut oci_tar_image, + &image_manifest, + &image_config, + rootfs_path.into(), + )?; + + Ok(image_config.config().clone()) +} + +fn extract_oci_image_rootfs<R: Read + Seek>( + oci_tar_image: &mut OciTarImage<R>, + image_manifest: &ImageManifest, + image_config: &ImageConfiguration, + target_path: PathBuf, +) -> oci_spec::Result<()> { + if !target_path.exists() { + return Err(OciSpecError::Other( + "Rootfs destination path not found".into(), + )); + } + + let layer_map = build_layer_map(oci_tar_image, image_manifest); + + for layer in image_config.rootfs().diff_ids() { + let layer_digest = oci_spec::image::Digest::from_str(layer)?; + + let layer_descriptor = match layer_map.get(&layer_digest) { + Some(descriptor) => descriptor, + None => { + return Err(OciSpecError::Other( + "Unknown layer digest found in rootfs.diff_ids".into(), + )); + } + }; + + let layer_file = oci_tar_image.open_blob(layer_descriptor.digest())?; + + let tar_file: Box<dyn Read> = match layer_descriptor.media_type() { + MediaType::ImageLayer | MediaType::ImageLayerNonDistributable => Box::new(layer_file), + MediaType::ImageLayerGzip | MediaType::ImageLayerNonDistributableGzip => { + Box::new(GzDecoder::new(layer_file)) + } + MediaType::ImageLayerZstd | MediaType::ImageLayerNonDistributableZstd => { + Box::new(zstd::Decoder::new(layer_file)?) + } + _ => { + return Err(OciSpecError::Other( + "Encountered invalid media type for rootfs layer".into(), + )); + } + }; + + let mut archive = Archive::new(tar_file); + archive.set_preserve_ownerships(true); + archive.unpack(&target_path)?; + } + + Ok(()) +} diff --git a/proxmox-oci/src/oci_tar_image.rs b/proxmox-oci/src/oci_tar_image.rs new file mode 100644 index 00000000..02199c75 --- /dev/null +++ b/proxmox-oci/src/oci_tar_image.rs @@ -0,0 +1,173 @@ +use oci_spec::image::{Arch, Digest, ImageIndex, ImageManifest, MediaType}; +use oci_spec::OciSpecError; +use std::cmp::min; +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::PathBuf; +use tar::Archive; + +#[derive(Clone)] +pub struct TarEntry { + offset: u64, + size: usize, +} + +pub struct TarEntryReader<'a, R: Read + Seek> { + tar_entry: TarEntry, + reader: &'a mut R, + position: u64, +} + +impl<'a, R: Read + Seek> TarEntryReader<'a, R> { + pub fn new(tar_entry: TarEntry, reader: &'a mut R) -> Self { + Self { + tar_entry, + reader, + position: 0, + } + } +} + +impl<R: Read + Seek> Read for TarEntryReader<'_, R> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + let max_read = min(buf.len(), self.tar_entry.size - self.position as usize); + let limited_buf = &mut buf[..max_read]; + self.reader + .seek(SeekFrom::Start(self.tar_entry.offset + self.position))?; + let bytes_read = self.reader.read(limited_buf)?; + self.position += bytes_read as u64; + + Ok(bytes_read) + } +} + +impl<R: Read + Seek> Seek for TarEntryReader<'_, R> { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> { + self.position = match pos { + SeekFrom::Start(position) => min(position, self.tar_entry.size as u64), + SeekFrom::End(offset) => { + if offset > self.tar_entry.size as i64 { + return Err(std::io::Error::new( + std::io::ErrorKind::NotSeekable, + "Tried to seek before the beginning of the file", + )); + } + + (if offset <= 0 { + self.tar_entry.size + } else { + self.tar_entry.size - offset as usize + }) as u64 + } + SeekFrom::Current(offset) => { + if (self.position as i64 + offset) < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::NotSeekable, + "Tried to seek before the beginning of the file", + )); + } + + min(self.position + offset as u64, self.tar_entry.size as u64) + } + }; + + Ok(self.position) + } +} + +struct TarArchive<R: Read + Seek> { + reader: R, + entries: HashMap<PathBuf, TarEntry>, +} + +impl<R: Read + Seek> TarArchive<R> { + pub fn new(reader: R) -> std::io::Result<Self> { + let mut archive = Archive::new(reader); + let entries = archive.entries_with_seek()?; + let mut entries_index = HashMap::new(); + + for entry in entries { + let entry = entry?; + let offset = entry.raw_file_position(); + let size = entry.size() as usize; + let path = entry.path()?.into_owned(); + let tar_entry = TarEntry { offset, size }; + entries_index.insert(path, tar_entry); + } + + Ok(Self { + reader: archive.into_inner(), + entries: entries_index, + }) + } + + pub fn get_inner_file( + &mut self, + inner_file_path: PathBuf, + ) -> std::io::Result<TarEntryReader<R>> { + let tar_entry = match self.entries.get(&inner_file_path) { + Some(tar_entry) => tar_entry, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "File not found in archive", + )); + } + }; + + Ok(TarEntryReader::new(tar_entry.clone(), &mut self.reader)) + } +} + +pub struct OciTarImage<R: Read + Seek> { + archive: TarArchive<R>, + image_index: ImageIndex, +} + +impl<R: Read + Seek> OciTarImage<R> { + pub fn new(reader: R) -> oci_spec::Result<Self> { + let mut archive = TarArchive::new(reader)?; + let index_file = archive.get_inner_file("index.json".into())?; + let image_index = ImageIndex::from_reader(index_file)?; + + Ok(Self { + archive, + image_index, + }) + } + + pub fn image_index(&self) -> &ImageIndex { + &self.image_index + } + + pub fn open_blob(&mut self, digest: &Digest) -> std::io::Result<TarEntryReader<R>> { + self.archive.get_inner_file(get_blob_path(digest)) + } + + pub fn image_manifest(&mut self, architecture: &Arch) -> oci_spec::Result<ImageManifest> { + let image_manifest_descriptor = match self.image_index.manifests().iter().find(|&x| { + x.media_type() == &MediaType::ImageManifest + && x.platform() + .as_ref() + .is_none_or(|platform| platform.architecture() == architecture) + }) { + Some(descriptor) => descriptor, + None => { + return Err(OciSpecError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Image manifest not found for architecture", + ))); + } + }; + + let image_manifest_file = self.open_blob(&image_manifest_descriptor.digest().clone())?; + + ImageManifest::from_reader(image_manifest_file) + } +} + +fn get_blob_path(digest: &Digest) -> PathBuf { + let algorithm = digest.algorithm().as_ref(); + let digest = digest.digest(); + PathBuf::from(format!("blobs/{algorithm}/{digest}")) +} -- 2.39.5 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel