server/lib/src/pull_intake/oci.rs
//! **An OCI registry as a remote source** (`kind: oci`): Chainguard's
//! `cgr.dev` first, and any registry that speaks the distribution API v2.
//!
//! An image is pulled WHOLE: the manifest (or image index) the client asked
//! for, every platform manifest the source keeps, each config and layer blob,
//! every digest checked, and with it the image's cosign signature and
//! attestations (the `sha256-<digest>.sig` and `.att` tags) and its OCI 1.1
//! referrers. The lot is ONE artifact on the intake chain: an OCI image layout
//! tar (`oci-layout`, `index.json`, `blobs/sha256/…`), plus
//! `holger/media-types.json` (each manifest's media type) and
//! `holger/provenance/<material>` (what the provenance verifier was handed), so
//! the sealed bytes can be verified again on the far side of an airgap.
//!
//! The registry door then serves the image from the SEALED layout, and only
//! when it is AVAILABLE.
//!
//! **The attached artifacts travel with it** (lane HOL-REFERRERS): the `.sig`,
//! `.att` and `.sbom` tag manifests of every image digest kept, and every OCI 1.1
//! referrer of each (a cosign bundle, a notation signature, an SBOM), each with its
//! config and layer blobs, are kept in the same layout (`holger/media-types.json`
//! lists them under `attached_tags` and `referrers`). They pass the chain as part
//! of the image, never on their own, and the door serves them so a client's
//! `cosign verify` or `notation verify` works through Holger, and through a
//! downstream the layout reaches by an export bundle.
use std::collections::BTreeMap;
use super::http::{Fetch, Reply};
use super::provenance::Material;
/// The manifest media types a pull asks for.
pub const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, \
application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json";
const INDEX_TYPES: [&str; 2] = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
];
const MAX_MANIFEST: u64 = 4 * 1024 * 1024;
/// `sha256:<64 lower hex>`, the only digest this door takes.
pub fn is_digest(s: &str) -> bool {
s.strip_prefix("sha256:").is_some_and(|h| {
h.len() == 64
&& h.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
})
}
/// A tag (`latest`, `3.12-dev`) as the distribution spec allows it.
pub fn is_tag(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& s.bytes()
.next()
.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
/// An image name (`chainguard/static`): lower-case path components.
pub fn is_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 255
&& s.split('/').all(|c| {
!c.is_empty()
&& c.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
})
&& c != "."
&& c != ".."
})
}
fn sha256_digest(b: &[u8]) -> String {
format!(
"sha256:{}",
crate::attest::ArtifactDigest::of_sha256(b).hex()
)
}
/// What a registry path below `/v2/<repository>/` asks for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OciPath {
Manifest {
name: String,
reference: String,
},
Blob {
name: String,
digest: String,
},
Tags {
name: String,
},
/// OCI 1.1 `GET <name>/referrers/<digest>[?artifactType=…]`.
Referrers {
name: String,
digest: String,
artifact_type: Option<String>,
},
}
/// Parse `<name…>/manifests/<ref>`, `<name…>/blobs/<digest>`, `<name…>/tags/list`,
/// `<name…>/referrers/<digest>`.
pub fn parse_path(rest: &str) -> Option<OciPath> {
let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
let rest = rest.trim_matches('/');
if let Some(name) = rest.strip_suffix("/tags/list") {
return is_name(name).then(|| OciPath::Tags { name: name.into() });
}
let (name, kind, last) = {
let (head, last) = rest.rsplit_once('/')?;
let (name, kind) = head.rsplit_once('/')?;
(name, kind, last)
};
if !is_name(name) {
return None;
}
match kind {
"manifests" if is_digest(last) || is_tag(last) => Some(OciPath::Manifest {
name: name.into(),
reference: last.into(),
}),
"blobs" if is_digest(last) => Some(OciPath::Blob {
name: name.into(),
digest: last.into(),
}),
"referrers" if is_digest(last) => Some(OciPath::Referrers {
name: name.into(),
digest: last.into(),
artifact_type: crate::referrers::artifact_type_query(query),
}),
_ => None,
}
}
/// **A registry client**: the distribution API v2 with the bearer-token dance
/// (anonymous for `cgr.dev`'s public images, basic credentials for a private one).
pub struct Registry<'a> {
pub http: &'a dyn Fetch,
/// `https://cgr.dev`.
pub base: String,
/// `Basic <b64>` for the token realm and the registry, when configured.
pub basic: Option<String>,
token: std::cell::RefCell<Option<String>>,
}
impl<'a> Registry<'a> {
pub fn new(http: &'a dyn Fetch, base: &str, basic: Option<String>) -> Self {
Self {
http,
base: base.trim_end_matches('/').to_string(),
basic,
token: std::cell::RefCell::new(None),
}
}
fn bearer_challenge(r: &Reply) -> Option<(String, Vec<(String, String)>)> {
let h = r.header("www-authenticate")?;
let rest = h
.strip_prefix("Bearer ")
.or_else(|| h.strip_prefix("bearer "))?;
let mut realm = None;
let mut params = Vec::new();
for part in split_params(rest) {
let (k, v) = part.split_once('=')?;
let v = v.trim().trim_matches('"').to_string();
match k.trim() {
"realm" => realm = Some(v),
k => params.push((k.to_string(), v)),
}
}
Some((realm?, params))
}
/// GET a path below `/v2/`, answering a token challenge once.
pub fn get(
&self,
path: &str,
accept: Option<&str>,
cap: u64,
scope_name: &str,
) -> anyhow::Result<Reply> {
let url = format!("{}/v2/{path}", self.base);
let mut headers: Vec<(String, String)> = Vec::new();
if let Some(a) = accept {
headers.push(("Accept".into(), a.into()));
}
let send = |token: &Option<String>| -> anyhow::Result<Reply> {
match (token, &self.basic) {
(Some(t), _) => self.http.get(
&url,
&headers,
Some((&self.base, &format!("Bearer {t}"))),
cap,
),
(None, Some(b)) => self.http.get(&url, &headers, Some((&self.base, b)), cap),
(None, None) => self.http.get(&url, &headers, None, cap),
}
};
let tok = self.token.borrow().clone();
let r = send(&tok)?;
if r.status != 401 {
return Ok(r);
}
let (realm, mut params) = Self::bearer_challenge(&r).ok_or_else(|| {
anyhow::anyhow!("{url}: HTTP 401 without a bearer challenge (credentials refused?)")
})?;
if !params.iter().any(|(k, _)| k == "scope") {
params.push(("scope".into(), format!("repository:{scope_name}:pull")));
}
let q: Vec<String> = params
.iter()
.map(|(k, v)| format!("{k}={}", url_encode(v)))
.collect();
let token_url = format!(
"{realm}{}{}",
if realm.contains('?') { "&" } else { "?" },
q.join("&")
);
let tr = self.http.get(
&token_url,
&[],
self.basic.as_deref().map(|b| (realm.as_str(), b)),
1024 * 1024,
)?;
if tr.status != 200 {
anyhow::bail!("{url}: the token realm {realm} answered HTTP {}", tr.status);
}
let v: serde_json::Value = serde_json::from_slice(&tr.body)
.map_err(|e| anyhow::anyhow!("token from {realm}: {e}"))?;
let t = v
.get("token")
.or_else(|| v.get("access_token"))
.and_then(|t| t.as_str())
.ok_or_else(|| anyhow::anyhow!("the token realm {realm} handed no token"))?
.to_string();
*self.token.borrow_mut() = Some(t);
let tok = self.token.borrow().clone();
send(&tok)
}
/// A manifest by tag or digest: its bytes, media type and digest (the
/// sha256 of the bytes; a digest reference must equal it). `None` on 404.
pub fn manifest(
&self,
name: &str,
reference: &str,
) -> anyhow::Result<Option<(Vec<u8>, String, String)>> {
let r = self.get(
&format!("{name}/manifests/{reference}"),
Some(MANIFEST_ACCEPT),
MAX_MANIFEST,
name,
)?;
match r.status {
200 => {}
404 => return Ok(None),
s => anyhow::bail!("{}/{name}: manifest {reference}: HTTP {s}", self.base),
}
let digest = sha256_digest(&r.body);
if is_digest(reference) && digest != reference {
anyhow::bail!(
"{}/{name}: manifest {reference} hashes to {digest}; not taken",
self.base
);
}
let mt = media_type_of(&r.body)
.or_else(|| r.header("content-type").map(str::to_string))
.unwrap_or_default();
Ok(Some((r.body, mt, digest)))
}
/// A blob, held against its digest.
pub fn blob(&self, name: &str, digest: &str, cap: u64) -> anyhow::Result<Vec<u8>> {
let r = self.get(&format!("{name}/blobs/{digest}"), None, cap, name)?;
if r.status != 200 {
anyhow::bail!("{}/{name}: blob {digest}: HTTP {}", self.base, r.status);
}
let got = sha256_digest(&r.body);
if got != digest {
anyhow::bail!(
"{}/{name}: blob {digest} hashes to {got}; not taken",
self.base
);
}
Ok(r.body)
}
}
fn split_params(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quoted = false;
for c in s.chars() {
match c {
'"' => {
quoted = !quoted;
cur.push(c);
}
',' if !quoted => {
out.push(std::mem::take(&mut cur));
}
c => cur.push(c),
}
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}
fn url_encode(s: &str) -> String {
let mut o = String::new();
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
o.push(b as char);
} else {
o.push_str(&format!("%{b:02X}"));
}
}
o
}
/// The `mediaType` a manifest declares about itself.
pub fn media_type_of(manifest: &[u8]) -> Option<String> {
serde_json::from_slice::<serde_json::Value>(manifest)
.ok()?
.get("mediaType")?
.as_str()
.map(str::to_string)
}
/// **One image, pulled whole.**
#[derive(Debug, Clone, Default)]
pub struct Pulled {
pub name: String,
/// The tag the client asked for, when it asked by tag.
pub tag: Option<String>,
/// The digest of the manifest or index the reference resolves to.
pub digest: String,
/// Every manifest (the top one and each platform's), by digest: (media type, bytes).
pub manifests: BTreeMap<String, (String, Vec<u8>)>,
/// Every config and layer blob, by digest.
pub blobs: BTreeMap<String, Vec<u8>>,
/// The cosign attachments and referrers, for the provenance verifier.
pub materials: Vec<Material>,
/// The image digests: the top manifest or index, then each platform manifest kept.
pub images: Vec<String>,
/// `sha256-<hex>.sig|.att|.sbom` → the digest of that attached manifest (in `manifests`).
pub attached_tags: BTreeMap<String, String>,
/// Subject digest → the descriptors of its referrers (each manifest in `manifests`).
pub referrers: BTreeMap<String, Vec<serde_json::Value>>,
}
impl Pulled {
pub fn top(&self) -> &[u8] {
self.manifests
.get(&self.digest)
.map(|(_, b)| b.as_slice())
.unwrap_or_default()
}
pub fn size(&self) -> u64 {
self.manifests
.values()
.map(|(_, b)| b.len() as u64)
.chain(self.blobs.values().map(|b| b.len() as u64))
.sum()
}
}
/// `linux/amd64` of a descriptor's platform, `unknown/unknown` for an attestation manifest.
fn platform_of(d: &serde_json::Value) -> String {
let p = d.get("platform");
let os = p
.and_then(|p| p.get("os"))
.and_then(|x| x.as_str())
.unwrap_or("unknown");
let arch = p
.and_then(|p| p.get("architecture"))
.and_then(|x| x.as_str())
.unwrap_or("unknown");
match p.and_then(|p| p.get("variant")).and_then(|x| x.as_str()) {
Some(v) => format!("{os}/{arch}/{v}"),
None => format!("{os}/{arch}"),
}
}
fn wants_platform(platforms: &[String], p: &str) -> bool {
platforms.is_empty()
|| platforms
.iter()
.any(|w| p == w || p.starts_with(&format!("{w}/")))
}
/// **Pull `name:reference`** with every platform `platforms` keeps (empty =
/// all), the blobs, and the signatures, attestations and referrers.
pub fn pull(
reg: &Registry<'_>,
name: &str,
reference: &str,
platforms: &[String],
cap: u64,
) -> anyhow::Result<Option<Pulled>> {
let Some((top, top_mt, digest)) = reg.manifest(name, reference)? else {
return Ok(None);
};
let mut p = Pulled {
name: name.to_string(),
tag: (!is_digest(reference)).then(|| reference.to_string()),
digest: digest.clone(),
..Default::default()
};
let mut images: Vec<(String, Vec<u8>)> = Vec::new();
if INDEX_TYPES.contains(&top_mt.as_str()) {
let v: serde_json::Value = serde_json::from_slice(&top)
.map_err(|e| anyhow::anyhow!("{name}@{digest}: the index is not JSON: {e}"))?;
let mut kept = 0;
for d in v
.get("manifests")
.and_then(|m| m.as_array())
.into_iter()
.flatten()
{
let plat = platform_of(d);
if plat == "unknown/unknown" || !wants_platform(platforms, &plat) {
continue;
}
let Some(md) = d
.get("digest")
.and_then(|x| x.as_str())
.filter(|x| is_digest(x))
else {
continue;
};
let (bytes, mt, got) = reg.manifest(name, md)?.ok_or_else(|| {
anyhow::anyhow!(
"{name}: the index names {md} ({plat}), which the registry does not have"
)
})?;
debug_assert_eq!(got, md);
p.manifests.insert(md.to_string(), (mt, bytes.clone()));
images.push((md.to_string(), bytes));
kept += 1;
}
if kept == 0 {
anyhow::bail!(
"{name}@{digest}: the index carries none of the platforms this source keeps ({})",
platforms.join(", ")
);
}
} else {
images.push((digest.clone(), top.clone()));
}
p.manifests.insert(digest.clone(), (top_mt, top));
p.images = std::iter::once(digest.clone())
.chain(
images
.iter()
.map(|(d, _)| d.clone())
.filter(|d| *d != digest),
)
.collect();
for (md, bytes) in images {
let v: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("{name}@{md}: not JSON: {e}"))?;
let config = v.pointer("/config/digest").and_then(|x| x.as_str());
let layers = v
.get("layers")
.and_then(|l| l.as_array())
.into_iter()
.flatten()
.filter_map(|l| l.get("digest").and_then(|x| x.as_str()));
for b in config.into_iter().chain(layers) {
if !is_digest(b) {
anyhow::bail!("{name}@{md}: '{b}' is not a sha256 digest");
}
if p.blobs.contains_key(b) {
continue;
}
let blob = reg.blob(name, b, cap)?;
p.blobs.insert(b.to_string(), blob);
if p.size() > cap {
anyhow::bail!(
"{name}@{digest}: the image is larger than this source's {cap}-byte cap"
);
}
}
}
attached(reg, name, cap, &mut p)?;
p.materials = materials_of(®.base, name, &p);
Ok(Some(p))
}
/// Keep one attached manifest and its config and layer blobs in `p`.
fn keep_attached(
reg: &Registry<'_>,
name: &str,
cap: u64,
p: &mut Pulled,
digest: &str,
media_type: String,
bytes: Vec<u8>,
) -> anyhow::Result<()> {
let v: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("{name}@{digest}: an attached manifest is not JSON: {e}"))?;
let config = v.pointer("/config/digest").and_then(|x| x.as_str());
let layers = v
.get("layers")
.and_then(|l| l.as_array())
.into_iter()
.flatten()
.filter_map(|l| l.get("digest").and_then(|x| x.as_str()));
for b in config.into_iter().chain(layers) {
if !is_digest(b) || p.blobs.contains_key(b) {
continue;
}
let blob = reg.blob(name, b, cap)?;
p.blobs.insert(b.to_string(), blob);
}
p.manifests.insert(digest.to_string(), (media_type, bytes));
if p.size() > cap {
anyhow::bail!("{name}: the image with its attached artifacts is larger than this source's {cap}-byte cap");
}
Ok(())
}
/// **The attached artifacts of every image digest**: the cosign tag scheme
/// (`sha256-<hex>.sig`, `.att`, `.sbom`) and the OCI 1.1 referrers, by the API or,
/// where the source has none, by the referrers tag schema (`sha256-<hex>`, an index).
fn attached(reg: &Registry<'_>, name: &str, cap: u64, p: &mut Pulled) -> anyhow::Result<()> {
for subject in p.images.clone() {
let hex = subject.trim_start_matches("sha256:").to_string();
for suffix in ["sig", "att", "sbom"] {
let tag = format!("sha256-{hex}.{suffix}");
let Some((m, mt, d)) = reg.manifest(name, &tag)? else {
continue;
};
keep_attached(reg, name, cap, p, &d, mt, m)?;
p.attached_tags.insert(tag, d);
}
let r = reg.get(
&format!("{name}/referrers/{subject}"),
Some(crate::referrers::INDEX_MEDIA_TYPE),
MAX_MANIFEST,
name,
)?;
let listed: Vec<serde_json::Value> = if r.status == 200 {
serde_json::from_slice::<serde_json::Value>(&r.body)
.ok()
.and_then(|v| v.get("manifests").and_then(|m| m.as_array()).cloned())
.unwrap_or_default()
} else {
match reg.manifest(name, &format!("sha256-{hex}"))? {
Some((m, _, _)) => serde_json::from_slice::<serde_json::Value>(&m)
.ok()
.and_then(|v| v.get("manifests").and_then(|m| m.as_array()).cloned())
.unwrap_or_default(),
None => Vec::new(),
}
};
let mut kept = Vec::new();
for desc in listed {
let Some(d) = desc
.get("digest")
.and_then(|x| x.as_str())
.filter(|x| is_digest(x))
.map(str::to_string)
else {
continue;
};
let Some((m, mt, _)) = reg.manifest(name, &d)? else {
continue;
};
keep_attached(reg, name, cap, p, &d, mt, m.clone())?;
// The descriptor as Holger reads it from the manifest itself, so it is
// true of the bytes kept whatever the source listed.
kept.push(crate::referrers::descriptor_of(&m));
}
if !kept.is_empty() {
p.referrers.insert(subject.clone(), kept);
}
}
Ok(())
}
/// **The provenance material of a pulled image**: one
/// `holger.cosign-attachments/v1` document with every `.sig` and `.att` layer of
/// the top digest (its annotations and its bytes), the shape `holger-provenance`
/// reads, and each referrer's sigstore bundle, read from what [`attached`] kept.
pub fn materials_of(base: &str, name: &str, p: &Pulled) -> Vec<Material> {
use base64::Engine as _;
let digest = p.digest.as_str();
let hex = digest.trim_start_matches("sha256:");
let mut doc = serde_json::json!({
"schema": "holger.cosign-attachments/v1",
"image": format!("{}/{name}", base.trim_start_matches("https://").trim_start_matches("http://")),
"digest": digest,
"signatures": [],
"attestations": [],
});
let manifest_of = |tag: &str| -> Option<serde_json::Value> {
let d = p.attached_tags.get(tag)?;
serde_json::from_slice(&p.manifests.get(d)?.1).ok()
};
let mut any = false;
for (suffix, key) in [("sig", "signatures"), ("att", "attestations")] {
let Some(v) = manifest_of(&format!("sha256-{hex}.{suffix}")) else {
continue;
};
for l in v
.get("layers")
.and_then(|l| l.as_array())
.into_iter()
.flatten()
{
let Some(d) = l
.get("digest")
.and_then(|x| x.as_str())
.filter(|d| is_digest(d))
else {
continue;
};
let Some(blob) = p.blobs.get(d) else { continue };
let layer = serde_json::json!({
"mediaType": l.get("mediaType").cloned().unwrap_or_default(),
"digest": d,
"annotations": l.get("annotations").cloned().unwrap_or(serde_json::json!({})),
"blob": base64::engine::general_purpose::STANDARD.encode(blob),
});
if let Some(arr) = doc.get_mut(key).and_then(|a| a.as_array_mut()) {
arr.push(layer);
any = true;
}
}
}
let mut out = Vec::new();
if any {
out.push(Material {
name: "cosign.json".into(),
bytes: serde_json::to_vec_pretty(&doc).unwrap_or_default(),
});
}
// OCI 1.1 referrers: each artifact manifest whose layer is a sigstore bundle.
for d in p.referrers.get(digest).into_iter().flatten() {
let Some(md) = d.get("digest").and_then(|x| x.as_str()) else {
continue;
};
let Some((_, m)) = p.manifests.get(md) else {
continue;
};
let mv: serde_json::Value = serde_json::from_slice(m).unwrap_or_default();
for l in mv
.get("layers")
.and_then(|l| l.as_array())
.into_iter()
.flatten()
{
let mt = l.get("mediaType").and_then(|x| x.as_str()).unwrap_or("");
let Some(ld) = l
.get("digest")
.and_then(|x| x.as_str())
.filter(|d| is_digest(d))
else {
continue;
};
if mt.contains("sigstore.bundle") || mt.contains("in-toto") || mt.contains("dsse") {
if let Some(blob) = p.blobs.get(ld) {
out.push(Material {
name: format!("referrer-{}.sigstore.json", &ld[7..19]),
bytes: blob.clone(),
});
}
}
}
}
out
}
// ── the OCI image layout the chain carries ──────────────────────────────────
/// **The image as one artifact**: an OCI image layout tar, deterministic
/// (sorted entries, mtime 0), with `holger/media-types.json` and the
/// provenance material.
pub fn layout_tar(p: &Pulled) -> anyhow::Result<Vec<u8>> {
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
files.insert(
"oci-layout".into(),
br#"{"imageLayoutVersion":"1.0.0"}"#.to_vec(),
);
let (top_mt, top) = p.manifests.get(&p.digest).cloned().unwrap_or_default();
let mut desc =
serde_json::json!({ "mediaType": top_mt, "digest": p.digest, "size": top.len() });
if let Some(t) = &p.tag {
desc["annotations"] = serde_json::json!({ "org.opencontainers.image.ref.name": t });
}
let index = serde_json::json!({ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [desc] });
files.insert("index.json".into(), serde_json::to_vec(&index)?);
let mut types = BTreeMap::new();
for (d, (mt, b)) in &p.manifests {
files.insert(format!("blobs/sha256/{}", &d[7..]), b.clone());
types.insert(d.clone(), mt.clone());
}
for (d, b) in &p.blobs {
files.insert(format!("blobs/sha256/{}", &d[7..]), b.clone());
}
let images: Vec<String> = if p.images.is_empty() {
vec![p.digest.clone()]
} else {
p.images.clone()
};
let meta = serde_json::json!({
"name": p.name, "tag": p.tag, "digest": p.digest, "manifests": types,
"images": images, "attached_tags": p.attached_tags, "referrers": p.referrers,
});
files.insert(
"holger/media-types.json".into(),
serde_json::to_vec_pretty(&meta)?,
);
for m in &p.materials {
files.insert(format!("holger/provenance/{}", m.name), m.bytes.clone());
}
let mut out = tar::Builder::new(Vec::new());
for (path, bytes) in &files {
let mut h = tar::Header::new_ustar();
h.set_size(bytes.len() as u64);
h.set_mode(0o644);
h.set_mtime(0);
h.set_uid(0);
h.set_gid(0);
h.set_entry_type(tar::EntryType::Regular);
out.append_data(&mut h, path, bytes.as_slice())?;
}
Ok(out.into_inner()?)
}
/// A sealed layout, read back for serving.
#[derive(Debug, Clone, Default)]
pub struct Layout {
pub name: String,
pub tag: Option<String>,
pub digest: String,
/// Manifest digest → media type.
pub manifests: BTreeMap<String, String>,
blobs: BTreeMap<String, Vec<u8>>,
pub materials: Vec<Material>,
/// The image digests (top first); a layout from before HOL-REFERRERS: its manifests.
pub images: Vec<String>,
/// `sha256-<hex>.sig|.att|.sbom` → the attached manifest's digest.
pub attached_tags: BTreeMap<String, String>,
/// Subject digest → its referrers' descriptors.
pub referrers: BTreeMap<String, Vec<serde_json::Value>>,
}
impl Layout {
/// Read a layout tar [`layout_tar`] wrote. Every blob is held against its name.
pub fn read(bytes: &[u8]) -> anyhow::Result<Self> {
use std::io::Read as _;
let mut ar = tar::Archive::new(bytes);
let mut out = Layout::default();
let mut meta: Option<serde_json::Value> = None;
for e in ar.entries()? {
let mut e = e?;
let path = e.path()?.to_string_lossy().to_string();
let mut b = Vec::new();
e.read_to_end(&mut b)?;
if let Some(hex) = path.strip_prefix("blobs/sha256/") {
let d = format!("sha256:{hex}");
if sha256_digest(&b) != d {
anyhow::bail!("the sealed layout's blob {d} does not hash to its name");
}
out.blobs.insert(d, b);
} else if path == "holger/media-types.json" {
meta = Some(serde_json::from_slice(&b)?);
} else if let Some(n) = path.strip_prefix("holger/provenance/") {
out.materials.push(Material {
name: n.to_string(),
bytes: b,
});
}
}
let meta =
meta.ok_or_else(|| anyhow::anyhow!("the layout carries no holger/media-types.json"))?;
out.name = meta
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
out.tag = meta.get("tag").and_then(|x| x.as_str()).map(str::to_string);
out.digest = meta
.get("digest")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if let Some(m) = meta.get("manifests").and_then(|m| m.as_object()) {
for (d, t) in m {
out.manifests
.insert(d.clone(), t.as_str().unwrap_or("").to_string());
}
}
out.images = match meta.get("images").and_then(|i| i.as_array()) {
Some(a) => a
.iter()
.filter_map(|x| x.as_str())
.map(str::to_string)
.collect(),
None => out.manifests.keys().cloned().collect(),
};
if let Some(t) = meta.get("attached_tags").and_then(|t| t.as_object()) {
for (tag, d) in t {
if let Some(d) = d.as_str() {
out.attached_tags.insert(tag.clone(), d.to_string());
}
}
}
if let Some(r) = meta.get("referrers").and_then(|r| r.as_object()) {
for (subject, list) in r {
out.referrers.insert(
subject.clone(),
list.as_array().cloned().unwrap_or_default(),
);
}
}
Ok(out)
}
/// A manifest's bytes and media type.
pub fn manifest(&self, digest: &str) -> Option<(&str, &[u8])> {
let mt = self.manifests.get(digest)?;
Some((mt.as_str(), self.blobs.get(digest)?.as_slice()))
}
/// Any blob (manifest, config, layer).
pub fn blob(&self, digest: &str) -> Option<&[u8]> {
self.blobs.get(digest).map(|b| b.as_slice())
}
/// Every digest the layout holds.
pub fn digests(&self) -> impl Iterator<Item = &String> {
self.blobs.keys()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_paths_parse_and_traversal_does_not() {
assert_eq!(
parse_path("cgr/chainguard/static/manifests/latest"),
Some(OciPath::Manifest {
name: "cgr/chainguard/static".into(),
reference: "latest".into()
})
);
let d = format!("sha256:{}", "a".repeat(64));
assert_eq!(
parse_path(&format!("cgr/x/blobs/{d}")),
Some(OciPath::Blob {
name: "cgr/x".into(),
digest: d
})
);
assert_eq!(
parse_path("cgr/x/tags/list"),
Some(OciPath::Tags {
name: "cgr/x".into()
})
);
assert_eq!(parse_path("cgr/../x/manifests/latest"), None);
assert_eq!(parse_path("cgr/x/blobs/sha256:zz"), None);
assert_eq!(parse_path("cgr/X/manifests/latest"), None);
}
#[test]
fn a_layout_reads_back_what_it_carries() {
let m = br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}"#.to_vec();
let d = sha256_digest(&m);
let layer = b"layer".to_vec();
let ld = sha256_digest(&layer);
let mut p = Pulled {
name: "x/y".into(),
tag: Some("latest".into()),
digest: d.clone(),
..Default::default()
};
p.manifests.insert(
d.clone(),
(
"application/vnd.oci.image.manifest.v1+json".into(),
m.clone(),
),
);
p.blobs.insert(ld.clone(), layer.clone());
p.materials.push(Material {
name: "cosign.json".into(),
bytes: b"{}".to_vec(),
});
let tar = layout_tar(&p).unwrap();
assert_eq!(tar, layout_tar(&p).unwrap(), "deterministic");
let l = Layout::read(&tar).unwrap();
assert_eq!(l.digest, d);
assert_eq!(l.manifest(&d).unwrap().1, m.as_slice());
assert_eq!(l.blob(&ld).unwrap(), layer.as_slice());
assert_eq!(l.materials.len(), 1);
assert_eq!(l.tag.as_deref(), Some("latest"));
}
}