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

Reply via email to