Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
777 changes: 735 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

21 changes: 8 additions & 13 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,26 @@ readme = "README.md"
keywords = ["oci", "git", "container", "docker", "image"]
categories = ["command-line-utilities"]

exclude = [
".github",
".claude",
"assets/*",
"tests/*",
".gitignore",
"CLAUDE.md",
"CONTRIBUTING.md",
"CODE_OF_CONDUCT.md",
]

[dependencies]
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tempfile = "3.20"
flate2 = "1.0"
walkdir = "2.5"
git2 = "0.20"
chrono = "0.4"
oci-spec = { version = "0.8.1", features = ["image"] }
indicatif = "0.18"
indicatif = "0.17"
log = "0.4"
env_logger = "0.11"
tar-rs = { package = "tar", version = "0.4" }
console = "0.15.11"
futures-util = "0.3.31"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
atty = "0.2.14"
tar = "0.4.44"
shiplift = "0.7.0"

[features]
# default = ["nerdctl", "docker"]
Expand Down
136 changes: 96 additions & 40 deletions src/extracted_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,22 @@
//! Temporary extraction is scoped to the instance lifetime via `tempfile::TempDir`.

use crate::metadata::{self, ImageMetadata};
use crate::notifier::Notifier;
use crate::tar_extractor;
use crate::notifier::AnyNotifier;
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use std::fs;
use std::fs::{self, File};
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use tar::Archive;

#[derive(Debug, Clone)]
pub struct Layer {
pub id: String,
pub command: String,
pub created_at: DateTime<Utc>,
pub is_empty: bool,
pub tarball_path: Option<std::path::PathBuf>, // Some for non-empty layers, None for empty layers
pub tarball_path: Option<PathBuf>, // Some for non-empty layers, None for empty layers
pub digest: String, // Always present - either tarball digest or "empty" for empty layers
pub comment: Option<String>, // Comment from image layer history
}
Expand All @@ -56,34 +58,93 @@ pub struct ExtractedImage {
}

impl ExtractedImage {
pub fn from_tarball<P: AsRef<Path>>(tarball_path: P, notifier: &Notifier) -> Result<Self> {
let tarball_path = tarball_path.as_ref();
// Quiet helper used by from_tarball (no bar unless you pass Some(&pb))
fn extract_tar_file(tar_path: &Path, extract_dir: &Path) -> Result<()> {
// Try to detect if the file is gzip compressed by checking the magic bytes
let mut file_for_detection = File::open(tar_path)?;
let mut magic_bytes = [0u8; 2];
file_for_detection.read_exact(&mut magic_bytes)?;

let mut cmd = Command::new("tar");

if magic_bytes == [0x1f, 0x8b] {
// This is a gzip file
cmd.arg("-xzf");
} else {
// This is a plain tar file
cmd.arg("-xf");
}

cmd.arg(tar_path).arg("-C").arg(extract_dir);

let output = cmd.output().context(format!(
"Failed to run tar command for file: {:?}",
tar_path
))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Failed to extract tar file {:?}: {}",
tar_path,
stderr
));
}

Ok(())
}

pub fn unpack_tar_with_progress(
tar_path: &Path,
out: &Path,
notifier: &AnyNotifier,
) -> anyhow::Result<()> {
let file = File::open(tar_path)?;
let total = file.metadata()?.len();

let pb = notifier.create_progress_bar(total, &format!("Image {}", tar_path.display()));
// // wrap the reader so reads advance the bar
let reader = BufReader::new(file);
let reader: Box<dyn Read> = if let Some(ref pb) = pb {
Box::new(pb.wrap_read(reader))
} else {
Box::new(reader)
};

let mut ar = Archive::new(reader);
ar.unpack(out)?;

if let Some(pb) = pb {
pb.finish_and_clear();
}

notifier.debug(&format!("Extracting image tarball: {tarball_path:?}"));
Ok(())
}

pub fn from_tarball<P: AsRef<Path>>(tarball_path: P, notifier: &AnyNotifier) -> Result<Self> {
let tarball_path = tarball_path.as_ref();

// Create a temporary directory for extraction
let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
let extract_dir = temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir)?;

// Extract the tarball
Self::extract_tar_file(tarball_path, &extract_dir)?;
match notifier {
AnyNotifier::Enhanced(_) => {
Self::unpack_tar_with_progress(tarball_path, &extract_dir, notifier)?;
}
AnyNotifier::Simple(_) => {
Self::unpack_tar_with_progress(tarball_path, &extract_dir, notifier)?;
}
}

// Verify the extracted content has the expected OCI structure
let manifest_path = extract_dir.join("manifest.json");
if !manifest_path.exists() {
return Err(anyhow!(
"Invalid image tarball: manifest.json not found. This does not appear to be a valid OCI/Docker image tarball."
));
return Err(anyhow!("Invalid image tarball: manifest.json not found."));
}

// Load metadata and layers using static helper methods
notifier.debug("Loading image metadata...");
let metadata = Self::load_metadata_from_dir(&extract_dir, "temp")?;

notifier.debug("Loading image layers...");
let layers = Self::load_layers_from_dir(&extract_dir)?;

notifier.info(&format!("Successfully loaded {} layers", layers.len()));

Ok(ExtractedImage {
Expand All @@ -94,6 +155,16 @@ impl ExtractedImage {
})
}

pub fn extract_layer_to<P: AsRef<Path>>(
&self,
layer_tarball: &Path,
output_dir: P,
) -> Result<()> {
let output_dir = output_dir.as_ref();
fs::create_dir_all(output_dir)?;

Self::extract_tar_file(layer_tarball, output_dir)
}
pub fn metadata(&self, _image_name: &str) -> Result<ImageMetadata> {
// Return the metadata as-is, keeping the proper SHA digest as ID
Ok(self.metadata.clone())
Expand All @@ -111,25 +182,10 @@ impl ExtractedImage {
Ok(self.layers.clone())
}

pub fn extract_layer_to<P: AsRef<Path>>(
&self,
layer_tarball: &Path,
output_dir: P,
) -> Result<()> {
let output_dir = output_dir.as_ref();
fs::create_dir_all(output_dir)?;
Self::extract_tar_file(layer_tarball, output_dir)
}

pub fn extract_dir(&self) -> &Path {
&self.extract_dir
}

fn extract_tar_file(tar_path: &Path, extract_dir: &Path) -> Result<()> {
tar_extractor::extract_tar(tar_path, extract_dir)
.context(format!("Failed to extract tar file: {tar_path:?}"))
}

fn load_metadata_from_dir(extract_dir: &Path, image_name: &str) -> Result<ImageMetadata> {
// Parse the manifest to get the config file path
let manifest_path = extract_dir.join("manifest.json");
Expand All @@ -151,7 +207,7 @@ impl ExtractedImage {
// Read the config file as JSON
let config_path = extract_dir.join(config_file);
let config_content = fs::read_to_string(&config_path)
.context(format!("Failed to read config file: {config_file}"))?;
.context(format!("Failed to read config file: {}", config_file))?;

// Parse as OCI ImageConfiguration
let config: oci_spec::image::ImageConfiguration =
Expand Down Expand Up @@ -180,9 +236,9 @@ impl ExtractedImage {
// Fallback: Extract digest from config file path (format: blobs/sha256/HASH)
if metadata.id.is_empty() {
if let Some(digest_hash) = config_file.strip_prefix("blobs/sha256/") {
metadata.id = format!("sha256:{digest_hash}");
metadata.id = format!("sha256:{}", digest_hash);
} else if let Some(digest_hash) = config_file.strip_suffix(".json") {
metadata.id = format!("sha256:{digest_hash}");
metadata.id = format!("sha256:{}", digest_hash);
}
}

Expand All @@ -199,7 +255,7 @@ impl ExtractedImage {
let path = PathBuf::from(image_name);
if let Some(filename) = path.file_stem() {
if let Some(name) = filename.to_str() {
metadata.repo_tags.push(format!("{name}:latest"));
metadata.repo_tags.push(format!("{}:latest", name));
}
}
}
Expand Down Expand Up @@ -228,7 +284,7 @@ impl ExtractedImage {
// Read the config file as JSON
let config_path = extract_dir.join(config_file);
let config_content = fs::read_to_string(&config_path)
.context(format!("Failed to read config file: {config_file}"))?;
.context(format!("Failed to read config file: {}", config_file))?;

let config: serde_json::Value =
serde_json::from_str(&config_content).context("Failed to parse image configuration")?;
Expand Down Expand Up @@ -302,7 +358,7 @@ impl ExtractedImage {
let id = tarball
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format!("layer-{i}"));
.unwrap_or_else(|| format!("layer-{}", i));

// Extract digest from tarball path
let digest =
Expand All @@ -311,7 +367,7 @@ impl ExtractedImage {
(id, Some(tarball.clone()), digest)
} else {
// Empty layer or no tarball available
let id = format!("<empty-layer-{i}>");
let id = format!("<empty-layer-{}>", i);
let digest = if is_empty {
"empty".to_string()
} else {
Expand Down
32 changes: 24 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
use crate::ProgressStyle::{Enhanced, Simple};
use anyhow::{anyhow, Result};
use clap::{Parser, ValueEnum};
use oci2git::notifier::{AnyNotifier, NotifierFlavor};
use oci2git::{DockerSource, ImageProcessor, NerdctlSource, TarSource};
use std::path::PathBuf;

use oci2git::{DockerSource, ImageProcessor, NerdctlSource, Notifier, TarSource};

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Engine {
Docker,
Nerdctl,
Tar,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ProgressStyle {
Simple,
Enhanced,
}

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
Expand All @@ -36,6 +43,15 @@ struct Cli {
)]
engine: Engine,

#[arg(
short,
long,
value_enum,
default_value = "enhanced",
help = "Progress style mode (default is enhanced, use `simple` for returning to simple mode)"
)]
progress: ProgressStyle,

#[arg(
short,
long,
Expand All @@ -47,16 +63,16 @@ struct Cli {

fn main() -> Result<()> {
let cli = Cli::parse();
let notifier_flavor = match cli.progress {
Simple => NotifierFlavor::Simple,
Enhanced => NotifierFlavor::Enhanced,
};

// Create notifier with verbosity level
let notifier = Notifier::new(cli.verbose);
let notifier = AnyNotifier::new(notifier_flavor, cli.verbose);
notifier.info(&format!("Progress style: {:?}", cli.progress));

notifier.debug(&format!("Output directory: {}", cli.output.display()));
notifier.debug(&format!("Engine: {:?}", cli.engine));
notifier.debug(&format!(
"Beautiful progress: {}",
notifier.use_beautiful_progress()
));

match cli.engine {
Engine::Docker => {
Expand Down
Loading