Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ The tool expects a directory in OCI image layout format, as produced by
./repositories
```

Without an `index.json`, `manifest.json` may instead hold a single image
manifest object (OCI, or Docker schema 2) as served by a registry, with its
layer blobs under `blobs/sha256/`.

Supported layer compression formats: gzip, zstd, bzip2, xz/lzma, and
uncompressed. Format is determined from the `mediaType` field in the manifest,
with magic byte detection as a fallback for layouts that omit `LayerSources`.
Expand Down
14 changes: 14 additions & 0 deletions ocirender-cli/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ struct Fixtures {
oci_layout: PathBuf,
docker_save: PathBuf,
docker_save_both: PathBuf,
bare_manifest: PathBuf,
}

/// Generate fixtures exactly once for the whole test run.
Expand All @@ -118,6 +119,7 @@ fn get_fixtures() -> &'static Fixtures {
oci_layout: images.oci_layout,
docker_save: images.docker_save,
docker_save_both: images.docker_save_both,
bare_manifest: images.bare_manifest,
_dir: dir,
}
})
Expand All @@ -136,6 +138,7 @@ fn get_fixtures_basic() -> &'static Fixtures {
oci_layout: images.oci_layout,
docker_save: images.docker_save,
docker_save_both: images.docker_save_both,
bare_manifest: images.bare_manifest,
_dir: dir,
}
})
Expand Down Expand Up @@ -361,6 +364,17 @@ fn e2e_both_metadata_files_prefers_index_json() {
);
}

/// A layout whose manifest.json is a bare OCI image manifest, as a registry
/// serves it, must convert like the OCI layout it was built from.
#[test]
fn e2e_bare_manifest_convert_and_verify() {
let fx = get_fixtures();
let work = TempDir::new().unwrap();
let squashfs = convert_squashfs(&fx.bare_manifest, work.path(), "bare-manifest");
let reference = umoci_unpack(&fx.oci_layout, "latest", work.path(), "bare-manifest-ref");
verify_squashfs_clean(&squashfs, &reference, false, "bare manifest.json");
}

/// Verify that the squashfs root directory has sane permissions (0755, root/root).
/// A missing root tar entry causes mksquashfs to default to 0777 owned by the
/// build user.
Expand Down
50 changes: 47 additions & 3 deletions ocirender-cli/tests/fixtures/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
//! Fixture generation for e2e tests.
//!
//! Produces three OCI image layout directories that share the same layer blobs
//! Produces four OCI image layout directories that share the same layer blobs
//! but differ in their metadata format:
//!
//! oci-layout/ — index.json only (OCI image layout)
//! docker-save/ — manifest.json only (Docker save format)
//! docker-save-both/ — both files present (index.json takes precedence)
//! bare-manifest/ — manifest.json holding a bare OCI image manifest
//!
//! The layers collectively exercise:
//! - All supported compression formats (uncompressed, gzip, zstd, bzip2, xz)
Expand All @@ -29,11 +30,12 @@ use tar::{Builder, EntryType, Header};

// ── public types ──────────────────────────────────────────────────────────────

/// Paths to the three generated image directories.
/// Paths to the four generated image directories.
pub struct FixtureImage {
pub oci_layout: PathBuf,
pub docker_save: PathBuf,
pub docker_save_both: PathBuf,
pub bare_manifest: PathBuf,
}

/// Generate all fixture images under `base_dir` and return their paths.
Expand Down Expand Up @@ -97,15 +99,17 @@ pub fn generate_fixtures(base_dir: &Path) -> Result<FixtureImage> {
})
.collect();

// Write three image layouts.
// Write four image layouts.
let oci_layout = write_oci_layout(base_dir, &digested)?;
let docker_save = write_docker_save(base_dir, &digested)?;
let docker_save_both = write_docker_save_both(base_dir, &digested)?;
let bare_manifest = write_bare_manifest(base_dir, &digested)?;

Ok(FixtureImage {
oci_layout,
docker_save,
docker_save_both,
bare_manifest,
})
}

Expand Down Expand Up @@ -470,6 +474,46 @@ fn write_docker_save(base: &Path, layers: &[DigestedBlob]) -> Result<PathBuf> {
Ok(dir)
}

/// Write a layout whose manifest.json is the bare OCI image manifest (as a
/// registry serves it) instead of a Docker save array, with no index.json.
fn write_bare_manifest(base: &Path, layers: &[DigestedBlob]) -> Result<PathBuf> {
let dir = base.join("bare-manifest");
let blobs_sha256 = dir.join("blobs").join("sha256");
std::fs::create_dir_all(&blobs_sha256)?;

for layer in layers {
std::fs::write(blobs_sha256.join(&layer.digest), &layer.data)?;
}

let config_json = make_config_json(layers);
let config_digest = sha256_hex(config_json.as_bytes());
std::fs::write(blobs_sha256.join(&config_digest), &config_json)?;

let manifest_layers: Vec<serde_json::Value> = layers
.iter()
.map(|l| {
serde_json::json!({
"mediaType": l.media_type,
"digest": l.digest_with_prefix(),
"size": l.size,
})
})
.collect();
let manifest = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": format!("sha256:{config_digest}"),
"size": config_json.len(),
},
"layers": manifest_layers,
});
std::fs::write(dir.join("manifest.json"), serde_json::to_vec(&manifest)?)
.context("writing manifest.json")?;
Ok(dir)
}

/// Write a layout with *both* index.json and manifest.json present.
/// The layer blobs and metadata are identical to the OCI layout so that
/// verify passes regardless of which file is parsed.
Expand Down
136 changes: 78 additions & 58 deletions src/image.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
//! OCI image layout parsing: reads `index.json` or `manifest.json` and
//! resolves layer descriptors to blob file paths on disk.
//!
//! Two image layout formats are supported:
//! Three image layout formats are supported:
//!
//! - **OCI image layout** (`index.json`): the standard format produced by
//! `skopeo copy docker://... oci:<dir>` and containerd. Layer blobs live
//! under `blobs/sha256/<hex-digest>`.
//! - **Docker save layout** (`manifest.json`): produced by
//! - **Docker save layout** (`manifest.json` holding an array): produced by
//! `docker save <image> | tar -x`. Layer paths are stored directly in the
//! manifest as relative paths like `blobs/sha256/<hex>`.
//! - **Bare image manifest** (`manifest.json` holding an object): a single
//! OCI or Docker schema 2 image manifest, as served by a registry, with its
//! layer blobs under `blobs/sha256/<hex-digest>`.
//!
//! The primary entry points are [`load_manifest`], which parses whichever
//! format is present and returns a normalised [`OciManifest`], and
Expand Down Expand Up @@ -103,8 +106,9 @@ pub fn detect_media_type(path: &Path) -> Result<&'static str> {
/// Load and return the image manifest from `image_dir`.
///
/// Tries `index.json` (OCI image layout) first, then falls back to
/// `manifest.json` (Docker save layout). Both formats are normalised into an
/// [`OciManifest`] before returning.
/// `manifest.json`, which may hold either a Docker save layout or a bare
/// image manifest. Every format is normalised into an [`OciManifest`] before
/// returning.
///
/// Returns an error if neither file is found, or if the manifest cannot be
/// parsed.
Expand All @@ -124,62 +128,18 @@ pub fn load_manifest(image_dir: &Path) -> Result<OciManifest> {

let manifest_path = image_dir.join("manifest.json");
if manifest_path.exists() {
// Docker save manifest.json has a different schema from the OCI manifest:
// it is an array of objects (one per tagged image), each with a `Layers`
// array of relative blob paths and an optional `LayerSources` map that
// carries media types. We only process the first image in the array.
#[derive(Deserialize)]
struct LayerSource {
#[serde(rename = "mediaType")]
media_type: String,
}

#[derive(Deserialize)]
struct DockerManifest {
#[serde(rename = "Layers")]
layers: Vec<String>,
/// Present in Docker save layouts produced by newer Docker versions
/// and by skopeo. Maps digest (`sha256:<hex>`) to a layer descriptor
/// carrying the media type. Absent in older layouts.
#[serde(rename = "LayerSources", default)]
layer_sources: HashMap<String, LayerSource>,
}

let data = std::fs::read_to_string(&manifest_path).context("reading manifest.json")?;
let manifests: Vec<DockerManifest> =
let root: serde_json::Value =
serde_json::from_str(&data).context("parsing manifest.json")?;
let dm = manifests
.into_iter()
.next()
.context("manifest.json is empty")?;

let layers = dm
.layers
.into_iter()
.map(|l| {
// Layer paths look like "blobs/sha256/<hex>".
// LayerSources keys look like "sha256:<hex>".
// Reconstruct the digest key from the path's final component.
let digest = l
.rsplit('/')
.next()
.map(|hex| format!("sha256:{hex}"))
.unwrap_or_default();
let media_type = dm
.layer_sources
.get(&digest)
.map(|s| s.media_type.clone())
// Empty string signals "unknown"; resolve_layers will fall
// back to magic byte detection for this layer.
.unwrap_or_default();
OciDescriptor {
digest: l,
media_type,
}
})
.collect();

return Ok(OciManifest { layers });
// `docker save` writes an array, one entry per tagged image. A lone
// object is instead a single image manifest, OCI or Docker schema 2
// alike, as in a layout assembled from a registry's responses rather
// than exported by a tool.
return if root.is_array() {
docker_save_manifest(root)
} else {
serde_json::from_value(root).context("parsing manifest.json as an image manifest")
};
}

bail!(
Expand All @@ -188,6 +148,66 @@ pub fn load_manifest(image_dir: &Path) -> Result<OciManifest> {
);
}

/// Normalise a Docker save `manifest.json` into an [`OciManifest`].
///
/// Docker save manifest.json has a different schema from the OCI manifest:
/// it is an array of objects (one per tagged image), each with a `Layers`
/// array of relative blob paths and an optional `LayerSources` map that
/// carries media types. Only the first image in the array is processed.
fn docker_save_manifest(root: serde_json::Value) -> Result<OciManifest> {
#[derive(Deserialize)]
struct LayerSource {
#[serde(rename = "mediaType")]
media_type: String,
}

#[derive(Deserialize)]
struct DockerManifest {
#[serde(rename = "Layers")]
layers: Vec<String>,
/// Present in Docker save layouts produced by newer Docker versions
/// and by skopeo. Maps digest (`sha256:<hex>`) to a layer descriptor
/// carrying the media type. Absent in older layouts.
#[serde(rename = "LayerSources", default)]
layer_sources: HashMap<String, LayerSource>,
}

let manifests: Vec<DockerManifest> =
serde_json::from_value(root).context("parsing manifest.json")?;
let dm = manifests
.into_iter()
.next()
.context("manifest.json is empty")?;

let layers = dm
.layers
.into_iter()
.map(|l| {
// Layer paths look like "blobs/sha256/<hex>".
// LayerSources keys look like "sha256:<hex>".
// Reconstruct the digest key from the path's final component.
let digest = l
.rsplit('/')
.next()
.map(|hex| format!("sha256:{hex}"))
.unwrap_or_default();
let media_type = dm
.layer_sources
.get(&digest)
.map(|s| s.media_type.clone())
// Empty string signals "unknown"; resolve_layers will fall
// back to magic byte detection for this layer.
.unwrap_or_default();
OciDescriptor {
digest: l,
media_type,
}
})
.collect();

Ok(OciManifest { layers })
}

/// Resolve an [`OciDescriptor`] to an [`OciManifest`], following one level of
/// nested index indirection if necessary.
///
Expand Down
Loading
Loading