server/lib/src/promote/oci_push.rs
//! **Scanned pushes** (lane HOL-OCI-EVIDENCE item 2, owner 2026-10-31): an image pushed to a
//! writable `oci` registry goes through the intake chain like any other upload.
//!
//! Until now the plain `oci` registry served what was pushed the moment the manifest landed:
//! no scan, no policy, no approval, and no Holger evidence. On a Holger with an intake chain,
//! every `ron_repo_type: "oci"` repository is now a **scanned registry** unless the RON opts it
//! out by name:
//!
//! ```ron
//! promotion: Some((intake: Some((…, oci_push: [ (repository: "scratch", scan: off) ])))),
//! ```
//!
//! An opt-out is refused at boot, by name, where the policy requires a scan
//! (`security_profile: Critical`, or `promotion.intake.scan.required` naming any kind):
//! `holger-oci-1`. A rule that names no `oci` repository is `holger-oci-2`.
//!
//! What a scanned registry does:
//!
//! * **writes** need the Importer duty (as an upload into `incoming` does). Blobs and
//! manifests land in the registry's own store, which now only STAGES them: nothing staged
//! is served. A `DELETE` is refused (`holger-oci-3`): the chain holds what was pushed.
//! * **a manifest pushed by TAG** (`docker push`, `skopeo copy`, `helm push`) takes the image
//! into the chain: Holger reads it back from the staging store whole (every platform
//! manifest of an index, each config and layer, its cosign `.sig`/`.att`/`.sbom` tags and
//! OCI 1.1 referrers already pushed), writes ONE image layout (`.oci.tar`, the same the
//! pull-through keeps), files the signatures in the provenance inbox, and INGESTS it into
//! `incoming` as the pusher, at `<registry>/<image>/sha256-<hex>.oci.tar`, origin
//! `<registry>` (a provenance gate for `<registry>` applies). Then it walks SCANNED (the
//! scan worker: inspection, malware, secrets, the image SBOM, the CVE match of the image's
//! packages, provenance) and POLICY_CHECKED, and waits for its approvals (never
//! automatic, and never the pusher's). A refusal answers the push itself: `403 DENIED`
//! with Holger's reason, so `docker push` fails with it.
//! * a manifest pushed by DIGEST (an index's platform manifests, before the index) and an
//! **attached artifact** (a `sha256-<hex>.sig|.att|.sbom` tag, a manifest with a `subject`)
//! are staged; an attachment pushed before the image's tag rides in its layout (so its
//! signature is verified on intake), one pushed after is served beside the image once
//! the image is AVAILABLE.
//! * **reads** serve only what is AVAILABLE: a manifest from its sealed layout (by digest, or
//! by the tag the staging store names, which must be AVAILABLE), its blobs, its tags, its
//! attached artifacts and Holger's own evidence (the scan verdict, the image SBOM) as OCI
//! referrers and in the `.att` tag ([`crate::referrers`]). Anything else is the registry's
//! "not yet" (`404 MANIFEST_UNKNOWN` naming the state) or `403 DENIED` once refused.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use traits::{ArtifactId, RepositoryBackendTrait};
use super::intake::{ArtifactState, IntakeChain, IntakeError, TransitionRequest};
use crate::lock::LockOrRecover as _;
use crate::pull_intake::http::{Fetch, Reply as HttpReply};
use crate::pull_intake::oci::{self, Layout, OciPath};
/// Whether a registry's pushes are scanned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PushScan {
/// Through the chain (the default for every `oci` repository of a chain Holger).
#[default]
Required,
/// Served as pushed, unscanned (refused where the policy requires a scan).
Off,
}
/// One `promotion.intake.oci_push` row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OciPushRule {
pub repository: String,
#[serde(default)]
pub scan: PushScan,
}
/// **The scanned registries of a chain**, by name. `oci_repos` are the config's
/// `ron_repo_type: "oci"` repositories, `scan_required` whether the policy requires a scan
/// (`Critical`, or `scan.required` naming a kind), `pull_sources` the pull-through's sources
/// (a scanned registry's layouts share their coordinates' first segment, so the names must
/// differ). A refusal is tagged.
pub fn scanned_registries(
rules: &[OciPushRule],
oci_repos: &[&str],
scan_required: Option<&str>,
pull_sources: &[&str],
) -> Result<BTreeSet<String>, String> {
let mut out: BTreeSet<String> = oci_repos.iter().map(|s| s.to_string()).collect();
let mut seen = BTreeSet::new();
for r in rules {
if !oci_repos.contains(&r.repository.as_str()) {
return Err(holger_errcode::OCI_PUSH_RULE_UNKNOWN.tag(format!(
"`promotion.intake.oci_push` names '{}', which is no `ron_repo_type: \"oci\"` repository of this Holger",
r.repository
)));
}
if !seen.insert(r.repository.as_str()) {
return Err(holger_errcode::OCI_PUSH_RULE_UNKNOWN.tag(format!(
"`promotion.intake.oci_push` names '{}' twice",
r.repository
)));
}
if r.scan == PushScan::Off {
if let Some(why) = scan_required {
return Err(holger_errcode::OCI_PUSH_SCAN_REQUIRED.tag(format!(
"`promotion.intake.oci_push: [(repository: \"{}\", scan: off)]` would serve pushed images unscanned, and this Holger's policy requires a scan ({why})",
r.repository
)));
}
out.remove(&r.repository);
}
}
for s in &out {
if pull_sources.contains(&s.as_str()) {
return Err(holger_errcode::OCI_PUSH_RULE_UNKNOWN.tag(format!(
"the scanned registry '{s}' and a pull-through source share the name; their images would share coordinates"
)));
}
}
Ok(out)
}
/// The chain coordinate of a pushed image (the pull-through's shape, the registry as source).
pub fn push_id(repo: &str, image: &str, digest: &str) -> ArtifactId {
ArtifactId {
namespace: None,
name: format!("{repo}/{image}/{}.oci.tar", digest.replace(':', "-")),
version: String::new(),
}
}
/// The largest image a push takes into the chain whole (every platform, every blob).
pub const MAX_IMAGE_BYTES: u64 = 8 << 30;
/// The layouts kept parsed in memory.
const LAYOUT_CACHE: usize = 32;
/// **A chain's scanned registries**, attached to the chain at boot.
pub struct OciPush {
scanned: BTreeSet<String>,
#[cfg(feature = "scanners")]
inbox: Option<crate::scanner::provenance::ProvenanceInbox>,
layouts: Mutex<Vec<(String, Arc<Layout>)>>,
}
impl std::fmt::Debug for OciPush {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OciPush")
.field("scanned", &self.scanned)
.finish()
}
}
impl OciPush {
pub fn new(scanned: BTreeSet<String>) -> Self {
Self {
scanned,
#[cfg(feature = "scanners")]
inbox: None,
layouts: Mutex::new(Vec::new()),
}
}
/// File a pushed image's signatures in the chain's provenance inbox too.
#[cfg(feature = "scanners")]
pub fn with_provenance_inbox(
mut self,
inbox: crate::scanner::provenance::ProvenanceInbox,
) -> Self {
self.inbox = Some(inbox);
self
}
/// Does `repo` scan its pushes?
pub fn scans(&self, repo: &str) -> bool {
self.scanned.contains(repo)
}
/// The scanned registries.
pub fn registries(&self) -> &BTreeSet<String> {
&self.scanned
}
fn layout(&self, chain: &IntakeChain, id: &ArtifactId) -> Option<Arc<Layout>> {
let sha = chain.status(id)?.sha256;
if let Some((_, l)) = self
.layouts
.lock_or_recover()
.iter()
.find(|(s, _)| *s == sha)
{
return Some(Arc::clone(l));
}
let bytes = chain
.serve(id)
.ok()
.flatten()
.or_else(|| chain.incoming_artifact(id).ok())?;
let l = Arc::new(
Layout::read(&bytes)
.map_err(|e| log::warn!("oci push: the layout of {}: {e}", id.name))
.ok()?,
);
let mut c = self.layouts.lock_or_recover();
c.push((sha, Arc::clone(&l)));
if c.len() > LAYOUT_CACHE {
c.remove(0);
}
Some(l)
}
}
// ── the staging store, read back as a registry ──────────────────────────────
/// The registry's own store as a [`Fetch`]: `local:/v2/<path>` is answered by the backend's
/// registry door, so the pull-through's registry client reads a push back whole.
struct Staged<'a> {
backend: &'a dyn RepositoryBackendTrait,
repo: &'a str,
}
impl Fetch for Staged<'_> {
fn get(
&self,
url: &str,
_headers: &[(String, String)],
_auth: Option<(&str, &str)>,
cap: u64,
) -> anyhow::Result<HttpReply> {
let path = url
.strip_prefix("local:/v2/")
.ok_or_else(|| anyhow::anyhow!("{url} is not a staged path"))?;
let (status, headers, body) =
self.backend
.handle_http2_request("GET", &format!("/v2/{}/{path}", self.repo), &[])?;
if body.len() as u64 > cap {
anyhow::bail!("{url}: {} bytes, past the cap of {cap}", body.len());
}
Ok(HttpReply {
status,
headers: headers
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect(),
body,
url: url.to_string(),
})
}
}
fn staged_manifest(
backend: &dyn RepositoryBackendTrait,
repo: &str,
image: &str,
reference: &str,
) -> Option<(String, Vec<u8>, String)> {
let (status, headers, body) = backend
.handle_http2_request(
"GET",
&format!("/v2/{repo}/{image}/manifests/{reference}"),
&[],
)
.ok()?;
if status != 200 {
return None;
}
let h = |n: &str| {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(n))
.map(|(_, v)| v.clone())
};
let digest =
h("Docker-Content-Digest").unwrap_or_else(|| crate::referrers::sha256_digest(&body));
let mt = h("Content-Type")
.or_else(|| oci::media_type_of(&body))
.unwrap_or_else(|| crate::referrers::MANIFEST_MEDIA_TYPE.into());
Some((mt, body, digest))
}
fn staged_blob(
backend: &dyn RepositoryBackendTrait,
repo: &str,
image: &str,
digest: &str,
) -> Option<Vec<u8>> {
let (status, _, body) = backend
.handle_http2_request("GET", &format!("/v2/{repo}/{image}/blobs/{digest}"), &[])
.ok()?;
(status == 200 && crate::referrers::sha256_digest(&body) == digest).then_some(body)
}
/// The staged referrers of `digest` (OCI 1.1, as the registry recorded them).
fn staged_referrers(
backend: &dyn RepositoryBackendTrait,
repo: &str,
image: &str,
digest: &str,
) -> Vec<Value> {
let Ok((200, _, body)) = backend.handle_http2_request(
"GET",
&format!("/v2/{repo}/{image}/referrers/{digest}"),
&[],
) else {
return Vec::new();
};
serde_json::from_slice::<Value>(&body)
.ok()
.and_then(|v| v.get("manifests").and_then(Value::as_array).cloned())
.unwrap_or_default()
}
// ── a push into the chain ───────────────────────────────────────────────────
/// What a manifest PUT did beside staging the manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pushed {
/// An attached artifact, or a manifest by digest: staged only.
Staged(String),
/// Taken into the chain: where it stands now.
InChain {
id: ArtifactId,
state: ArtifactState,
line: String,
},
/// The chain refused it: the reason, tagged.
Refused { id: Option<ArtifactId>, why: String },
}
/// The most bytes an attachment's blobs may declare together (SEC-REVIEW 2026-11-01 P7): a
/// signature, an attestation or an SBOM, never an image.
pub const ATTACHMENT_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// **Is this manifest an inert artifact** (SEC-REVIEW 2026-11-01 P7): one manifest (no index),
/// no layer of an image's layer types (`application/vnd.oci.image.layer.*`, Docker's
/// `rootfs.diff`), every blob's size declared and all of them within
/// [`ATTACHMENT_MAX_BYTES`]. Cosign's signatures and attestations and the SBOM referrers are;
/// a runnable image is not, whatever it names as its `subject`.
pub fn is_inert_attachment(manifest: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<Value>(manifest) else {
return false;
};
if v.get("manifests").is_some() {
return false;
}
let layers = v
.get("layers")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut total: u64 = 0;
for blob in layers.iter().chain(v.get("config")) {
let Some(size) = blob.get("size").and_then(Value::as_u64) else {
return false;
};
total = total.saturating_add(size);
}
let runnable = layers.iter().any(|l| {
let mt = l.get("mediaType").and_then(Value::as_str).unwrap_or("");
mt.starts_with("application/vnd.oci.image.layer.")
|| mt.starts_with("application/vnd.docker.image.rootfs.")
});
!runnable && total <= ATTACHMENT_MAX_BYTES
}
fn is_attachment(reference: &str, manifest: &[u8]) -> bool {
(crate::referrers::cosign_tag(reference).is_some()
|| serde_json::from_slice::<Value>(manifest)
.ok()
.is_some_and(|v| v.get("subject").is_some_and(|s| !s.is_null())))
&& is_inert_attachment(manifest)
}
impl OciPush {
/// **A manifest PUT into the scanned registry `repo` landed** (the backend staged it).
/// A tag takes the image into the chain as `actor` (from `peer`) and walks it as far as
/// policy lets it.
#[allow(clippy::too_many_arguments)]
pub fn manifest_pushed(
&self,
chain: &IntakeChain,
backend: &dyn RepositoryBackendTrait,
repo: &str,
image: &str,
reference: &str,
manifest: &[u8],
actor: &str,
peer: &str,
) -> Pushed {
if is_attachment(reference, manifest) {
return Pushed::Staged(format!("{image}:{reference}: an attached artifact, served beside its image once that is AVAILABLE"));
}
if oci::is_digest(reference) {
return Pushed::Staged(format!(
"{image}@{reference}: staged; a tag that names it takes the image into the chain"
));
}
let fetch = Staged { backend, repo };
let reg = oci::Registry::new(&fetch, "local:", None);
let pulled = match oci::pull(®, image, reference, &[], MAX_IMAGE_BYTES) {
Ok(Some(p)) => p,
Ok(None) => {
return Pushed::Refused {
id: None,
why: format!(
"{image}:{reference} is not in the registry's store after its push"
),
}
}
Err(e) => {
return Pushed::Refused {
id: None,
why: format!("{image}:{reference} cannot be read back whole: {e:#}"),
}
}
};
let id = push_id(repo, image, &pulled.digest);
if let Some(st) = chain.status(&id) {
// The same image under another tag: it is in the chain already.
return Pushed::InChain {
id,
state: st.state,
line: format!(
"{image}@{} is in the chain already ({})",
pulled.digest, st.state
),
};
}
let tar = match oci::layout_tar(&pulled) {
Ok(t) => t,
Err(e) => {
return Pushed::Refused {
id: Some(id),
why: format!("the image layout could not be written: {e:#}"),
}
}
};
#[cfg(feature = "scanners")]
if let Some(inbox) = &self.inbox {
let sha = crate::attest::ArtifactDigest::of_sha256(&tar)
.hex()
.to_string();
for m in &pulled.materials {
let safe: String = m
.name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
if let Err(e) = inbox.put(&sha, safe.trim_start_matches('.'), &m.bytes) {
return Pushed::Refused { id: Some(id), why: format!("the image's signatures could not be filed for the provenance check: {e:#}") };
}
}
}
if let Err(e) = chain.ingest_pushed(actor, peer, repo, &id, &tar) {
return Pushed::Refused {
id: Some(id),
why: e.tagged(),
};
}
self.walk(chain, repo, &id)
}
/// **An approved image goes on to AVAILABLE** (SEALED, PROMOTED, AVAILABLE) when it is
/// next asked for, as a pulled one does; where it stands then.
fn finish(&self, chain: &IntakeChain, repo: &str, id: &ArtifactId) -> ArtifactState {
let actor = format!("oci-push:{repo}");
loop {
let s = chain.state(id);
let next = match s {
ArtifactState::Approved => ArtifactState::Sealed,
ArtifactState::Sealed => ArtifactState::Promoted,
ArtifactState::Promoted => ArtifactState::Available,
s => return s,
};
if let Err(e) = chain
.advance(TransitionRequest::new(id.clone(), next, &actor).from_source("oci-push"))
{
log::warn!(
"oci push: {} could not go on to {next}: {}",
id.name,
e.tagged()
);
return chain.state(id);
}
}
}
/// INGESTED → SCANNED → POLICY_CHECKED, then the approvals; a refusal blocks it.
fn walk(&self, chain: &IntakeChain, repo: &str, id: &ArtifactId) -> Pushed {
let actor = format!("oci-push:{repo}");
loop {
let Some(st) = chain.status(id) else {
return Pushed::Refused {
id: Some(id.clone()),
why: "not in the chain".into(),
};
};
let to = match st.state {
ArtifactState::Ingested => ArtifactState::Scanned,
ArtifactState::Scanned => ArtifactState::PolicyChecked,
s => {
return Pushed::InChain {
id: id.clone(),
state: s,
line: match s {
ArtifactState::PolicyChecked => {
format!("{}: POLICY_CHECKED, waiting for its approval(s)", id.name)
}
s => format!("{}: {s}", id.name),
},
}
}
};
let step = chain.advance(
TransitionRequest::new(id.clone(), to, &actor)
.from_source("oci-push")
.with_reason(format!("pushed to '{repo}'")),
);
if let Err(e) = step {
let why = e.tagged();
let block = TransitionRequest::new(id.clone(), ArtifactState::Blocked, &actor)
.from_source("oci-push")
.with_reason(format!("refused on the push to '{repo}': {why}"));
return match chain.advance(block) {
Ok(_) => Pushed::Refused {
id: Some(id.clone()),
why,
},
Err(e2) => Pushed::Refused {
id: Some(id.clone()),
why: format!("{why}; the block failed: {}", e2.tagged()),
},
};
}
}
}
}
// ── the registry door of a scanned registry ─────────────────────────────────
/// One answer of the door.
#[derive(Debug, Clone)]
pub struct Reply {
pub status: u16,
pub headers: Vec<(&'static str, String)>,
pub body: Vec<u8>,
/// The audit note.
pub note: String,
}
impl Reply {
fn with(mut self, k: &'static str, v: String) -> Self {
self.headers.push((k, v));
self
}
}
/// A registry error body.
pub fn registry_error(status: u16, code: &str, message: String) -> Reply {
let body =
serde_json::json!({ "errors": [ { "code": code, "message": message, "detail": {} } ] })
.to_string()
.into_bytes();
Reply {
status,
headers: vec![
("Content-Type", "application/json".into()),
("Docker-Distribution-API-Version", "registry/2.0".into()),
],
body,
note: format!("{code}: {message}"),
}
}
fn manifest_reply(mt: String, body: Vec<u8>, note: String) -> Reply {
let digest = crate::referrers::sha256_digest(&body);
Reply {
status: 200,
headers: vec![
("Content-Type", mt),
("Docker-Content-Digest", digest),
("Docker-Distribution-API-Version", "registry/2.0".into()),
("X-Holger-Intake-State", "AVAILABLE".into()),
],
body,
note,
}
}
fn blob_reply(digest: String, body: Vec<u8>, note: String) -> Reply {
Reply {
status: 200,
headers: vec![
("Content-Type", "application/octet-stream".into()),
("Docker-Content-Digest", digest),
("X-Holger-Intake-State", "AVAILABLE".into()),
],
body,
note,
}
}
/// **What an image of a scanned registry shows**: its AVAILABLE layouts, Holger's evidence
/// about each image digest, and what was attached to them after they were taken in.
struct View<'a> {
backend: &'a dyn RepositoryBackendTrait,
repo: &'a str,
image: &'a str,
layouts: Vec<Arc<Layout>>,
evidence: crate::referrers::Evidence,
/// `sha256-<hex>.att` → the merged manifest (own layers, then Holger's).
att: BTreeMap<String, Vec<u8>>,
}
impl<'a> View<'a> {
fn of(
push: &OciPush,
chain: &IntakeChain,
backend: &'a dyn RepositoryBackendTrait,
attest: Option<&crate::attest::AttestStore>,
repo: &'a str,
image: &'a str,
) -> Self {
let layouts: Vec<Arc<Layout>> = ids(chain, repo, image)
.into_iter()
.filter(|id| push.finish(chain, repo, id) == ArtifactState::Available)
.filter_map(|id| push.layout(chain, &id))
.collect();
let mut evidence = crate::referrers::Evidence::default();
let mut att = BTreeMap::new();
for l in &layouts {
for d in &l.images {
let Some((mt, m)) = l.manifest(d) else {
continue;
};
let mut e =
crate::referrers::Evidence::of(attest, &crate::referrers::Subject::of(mt, m));
let tag = format!("sha256-{}.att", d.trim_start_matches("sha256:"));
// The image's own `.att`: what was pushed since (it may carry Holger's layers
// already, which are not added twice), else what rode in its layout.
let own = staged_manifest(backend, repo, image, &tag)
.map(|(_, b, _)| b)
.or_else(|| {
l.attached_tags
.get(&tag)
.and_then(|ad| l.manifest(ad))
.map(|(_, b)| b.to_vec())
});
if let Some(m) = e.att_manifest(own.as_deref()) {
att.insert(tag, m);
}
evidence.absorb(e);
}
}
Self {
backend,
repo,
image,
layouts,
evidence,
att,
}
}
/// Is `digest` an image digest of an AVAILABLE layout?
fn available(&self, digest: &str) -> bool {
self.layouts
.iter()
.any(|l| l.images.iter().any(|d| d == digest))
}
fn manifest(&self, digest: &str) -> Option<(String, Vec<u8>)> {
self.layouts.iter().find_map(|l| {
l.manifest(digest)
.map(|(mt, b)| (mt.to_string(), b.to_vec()))
})
}
/// The attached artifacts pushed after the image was taken in: `(digest, (mt, bytes))`
/// of every staged manifest that is a referrer or a cosign tag of an AVAILABLE digest.
fn staged_attachment(&self, digest: &str) -> Option<(String, Vec<u8>)> {
let (mt, body, d) = staged_manifest(self.backend, self.repo, self.image, digest)?;
if d != digest {
return None;
}
if !is_inert_attachment(&body) {
return None;
}
let v: Value = serde_json::from_slice(&body).ok()?;
let subject = v.pointer("/subject/digest").and_then(Value::as_str)?;
self.available(subject).then_some((mt, body))
}
/// Every blob an attachment of an AVAILABLE digest (pushed after) names.
fn staged_attachment_blob(&self, digest: &str) -> Option<Vec<u8>> {
for l in &self.layouts {
for d in &l.images {
let hex = d.trim_start_matches("sha256:");
let mut manifests: Vec<Vec<u8>> = Vec::new();
for suffix in ["sig", "att", "sbom"] {
if let Some((_, b, _)) = staged_manifest(
self.backend,
self.repo,
self.image,
&format!("sha256-{hex}.{suffix}"),
) {
manifests.push(b);
}
}
for r in staged_referrers(self.backend, self.repo, self.image, d) {
if let Some(rd) = r.get("digest").and_then(Value::as_str) {
if let Some((_, b, _)) =
staged_manifest(self.backend, self.repo, self.image, rd)
{
manifests.push(b);
}
}
}
for m in manifests.into_iter().filter(|m| is_inert_attachment(m)) {
let v: Value = serde_json::from_slice(&m).unwrap_or_default();
let names = v
.pointer("/config/digest")
.and_then(Value::as_str)
.into_iter()
.chain(
v.get("layers")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|x| x.get("digest").and_then(Value::as_str)),
);
if names.into_iter().any(|n| n == digest) {
return staged_blob(self.backend, self.repo, self.image, digest);
}
}
}
}
None
}
fn tag(&self, tag: &str) -> Option<(String, Vec<u8>)> {
let (subject, _) = crate::referrers::cosign_tag(tag)?;
if !self.available(&subject) {
return None;
}
if let Some(m) = self.att.get(tag) {
return Some((crate::referrers::MANIFEST_MEDIA_TYPE.to_string(), m.clone()));
}
if let Some((mt, b, _)) = staged_manifest(self.backend, self.repo, self.image, tag) {
return is_inert_attachment(&b).then_some((mt, b));
}
self.layouts.iter().find_map(|l| {
l.attached_tags
.get(tag)
.and_then(|d| l.manifest(d))
.map(|(mt, b)| (mt.to_string(), b.to_vec()))
})
}
fn referrers(&self, digest: &str) -> Vec<Value> {
if !self.available(digest) {
return Vec::new();
}
let mut out: Vec<Value> = self
.layouts
.iter()
.flat_map(|l| l.referrers.get(digest).cloned().unwrap_or_default())
.collect();
out.extend(staged_referrers(
self.backend,
self.repo,
self.image,
digest,
));
out.extend(
self.evidence
.referrers
.iter()
.filter(|r| {
self.evidence
.object(r.get("digest").and_then(Value::as_str).unwrap_or(""))
.and_then(|(_, m)| serde_json::from_slice::<Value>(m).ok())
.is_some_and(|m| {
m.pointer("/subject/digest").and_then(Value::as_str) == Some(digest)
})
})
.cloned(),
);
out
}
}
/// The chain coordinates of `image`'s pushed layouts in `repo`.
fn ids(chain: &IntakeChain, repo: &str, image: &str) -> Vec<ArtifactId> {
let prefix = format!("{repo}/{image}/");
chain
.all()
.into_iter()
.map(|(id, _)| id)
.filter(|id| {
id.name
.strip_prefix(&prefix)
.is_some_and(|r| !r.contains('/') && r.ends_with(".oci.tar"))
})
.collect()
}
/// "Not yet", in the registry's words: where the image stands in the chain.
fn not_yet(chain: &IntakeChain, id: &ArtifactId, what: &str) -> Reply {
let st = chain.status(id);
match st.as_ref().map(|s| s.state) {
Some(ArtifactState::Blocked) | Some(ArtifactState::Quarantined) => registry_error(
403,
"DENIED",
format!(
"{what} was refused by Holger's intake chain and is never served: {}",
st.map(|s| s.last.reason).unwrap_or_default()
),
),
Some(s) => registry_error(
404,
"MANIFEST_UNKNOWN",
format!("{what} is not yet AVAILABLE: Holger's intake chain holds it at {s}"),
),
None => registry_error(404, "MANIFEST_UNKNOWN", format!("{what}: no such image")),
}
}
impl OciPush {
/// **A read below `/v2/<repo>/`** of a scanned registry. `rest` is the path below the
/// repository (with its query); `attest` the store Holger's evidence is read from.
///
/// `pusher`: the reader may push here (write on the registry and the Importer duty). A
/// pusher reads its own STAGED manifest back by digest (lane HOL-FIELD-2): `helm push`
/// 3.18+ and oras push by digest, read it back, then PUT it under the tag. Only the
/// manifest; a staged blob is never served, to anyone.
pub fn read(
&self,
chain: &IntakeChain,
backend: &dyn RepositoryBackendTrait,
attest: Option<&crate::attest::AttestStore>,
repo: &str,
rest: &str,
pusher: bool,
) -> Reply {
let Some(p) = oci::parse_path(rest) else {
return registry_error(
404,
"NAME_UNKNOWN",
format!("'{rest}' is not a registry path Holger serves"),
);
};
match p {
OciPath::Referrers {
name,
digest,
artifact_type,
} => {
let v = View::of(self, chain, backend, attest, repo, &name);
let found = v.referrers(&digest);
let n = found.len();
let (body, filtered) = crate::referrers::index(found, artifact_type.as_deref());
let mut r = Reply {
status: 200,
headers: vec![("Content-Type", crate::referrers::INDEX_MEDIA_TYPE.into()), ("Docker-Distribution-API-Version", "registry/2.0".into())],
body,
note: format!("oci push: the referrers of {name}@{digest} ({n} before any filter), of an AVAILABLE image"),
};
if filtered {
r = r.with("OCI-Filters-Applied", "artifactType".into());
}
r
}
OciPath::Manifest { name, reference }
if crate::referrers::cosign_tag(&reference).is_some() =>
{
let v = View::of(self, chain, backend, attest, repo, &name);
match v.tag(&reference) {
Some((mt, body)) => manifest_reply(
mt,
body,
format!("oci push: {name}:{reference}, attached to an AVAILABLE image"),
),
None => registry_error(
404,
"MANIFEST_UNKNOWN",
format!("{name}:{reference}: no AVAILABLE image carries it"),
),
}
}
OciPath::Manifest { name, reference } => {
let v = View::of(self, chain, backend, attest, repo, &name);
// By tag: the digest the staging store names now, which must be AVAILABLE.
let digest = if oci::is_digest(&reference) {
reference.clone()
} else {
match staged_manifest(backend, repo, &name, &reference) {
Some((_, _, d)) => d,
None => {
return registry_error(
404,
"MANIFEST_UNKNOWN",
format!("{name}:{reference}: no such tag"),
)
}
}
};
if let Some((mt, body)) = v.manifest(&digest) {
return manifest_reply(
mt,
body,
format!(
"oci push: {name}:{reference} AVAILABLE, served from the sealed layout"
),
);
}
if let Some((mt, body)) = v
.evidence
.object(&digest)
.map(|(m, b)| (m.to_string(), b.to_vec()))
{
return manifest_reply(
mt,
body,
format!("oci push: Holger's evidence about {name}, {digest}"),
);
}
if let Some((mt, body)) = v.staged_attachment(&digest) {
return manifest_reply(
mt,
body,
format!("oci push: {name}@{digest}, attached to an AVAILABLE image"),
);
}
let id = push_id(repo, &name, &digest);
let refused = matches!(
chain.status(&id).map(|s| s.state),
Some(ArtifactState::Blocked | ArtifactState::Quarantined)
);
if pusher && oci::is_digest(&reference) && !refused {
if let Some((mt, body, d)) = staged_manifest(backend, repo, &name, &digest) {
if d == digest {
return manifest_reply(
mt,
body,
format!(
"oci push: {name}@{digest} STAGED, read back by a pusher \
(a digest push before its tag); never served to readers"
),
);
}
}
}
not_yet(
chain,
&push_id(repo, &name, &digest),
&format!("{name}:{reference}"),
)
}
OciPath::Blob { name, digest } => {
let v = View::of(self, chain, backend, attest, repo, &name);
if let Some(b) = v
.layouts
.iter()
.find_map(|l| l.blob(&digest).map(<[u8]>::to_vec))
{
return blob_reply(
digest,
b,
format!(
"oci push: a blob of {name} (AVAILABLE), served from the sealed layout"
),
);
}
if let Some((_, b)) = v.evidence.object(&digest) {
return blob_reply(
digest.clone(),
b.to_vec(),
format!("oci push: Holger's evidence about {name}"),
);
}
if let Some(b) = v.staged_attachment_blob(&digest) {
return blob_reply(
digest,
b,
format!("oci push: a blob attached to an AVAILABLE image of {name}"),
);
}
registry_error(
404,
"BLOB_UNKNOWN",
format!("{digest} is in no AVAILABLE image of {name}"),
)
}
OciPath::Tags { name } => {
let v = View::of(self, chain, backend, attest, repo, &name);
let staged: Vec<String> = backend
.handle_http2_request("GET", &format!("/v2/{repo}/{name}/tags/list"), &[])
.ok()
.filter(|(s, _, _)| *s == 200)
.and_then(|(_, _, b)| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("tags").and_then(Value::as_array).cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect();
let tags: Vec<String> = staged
.into_iter()
.filter(|t| match crate::referrers::cosign_tag(t) {
Some((subject, _)) => v.available(&subject),
None => staged_manifest(backend, repo, &name, t)
.is_some_and(|(_, _, d)| v.available(&d)),
})
.collect();
let body = serde_json::json!({ "name": format!("{repo}/{name}"), "tags": tags })
.to_string()
.into_bytes();
Reply {
status: 200,
headers: vec![("Content-Type", "application/json".into())],
body,
note: format!("oci push: the AVAILABLE tags of {name}"),
}
}
}
}
}
/// The registry path below the repository: `/v2/<repo>/<rest>` → `<rest>`.
pub fn rest_of<'p>(path_and_query: &'p str, repo: &str) -> Option<&'p str> {
path_and_query.strip_prefix(&format!("/v2/{repo}/"))
}
/// `(image, reference)` of a manifest path below the repository.
pub fn manifest_target(rest: &str) -> Option<(String, String)> {
match oci::parse_path(rest)? {
OciPath::Manifest { name, reference } => Some((name, reference)),
_ => None,
}
}
/// The refusal of a write a scanned registry never takes.
pub fn delete_refused(what: &str) -> IntakeError {
IntakeError::DoorRefused {
repo: what.to_string(),
why: holger_errcode::OCI_PUSH_DELETE_REFUSED.tag("a scanned registry never deletes or rewrites what was pushed: the intake chain holds it"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(repo: &str, scan: PushScan) -> OciPushRule {
OciPushRule {
repository: repo.into(),
scan,
}
}
#[test]
fn every_oci_registry_is_scanned_unless_the_ron_opts_it_out() {
let s = scanned_registries(&[], &["oci", "scratch"], None, &[]).unwrap();
assert_eq!(
s.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["oci", "scratch"]
);
let s = scanned_registries(
&[rule("scratch", PushScan::Off)],
&["oci", "scratch"],
None,
&[],
)
.unwrap();
assert_eq!(
s.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["oci"]
);
let ron: Vec<OciPushRule> =
ron::from_str(r#"[(repository: "scratch", scan: off), (repository: "oci")]"#).unwrap();
assert_eq!(
ron,
vec![
rule("scratch", PushScan::Off),
rule("oci", PushScan::Required)
]
);
assert!(ron::from_str::<Vec<OciPushRule>>(r#"[(repository: "x", scan: maybe)]"#).is_err());
}
#[test]
fn an_opt_out_where_the_policy_requires_a_scan_is_refused_by_name() {
let e = scanned_registries(
&[rule("oci", PushScan::Off)],
&["oci"],
Some("security_profile: Critical"),
&[],
)
.unwrap_err();
assert!(
e.contains("[holger-oci-1]") && e.contains("Critical"),
"{e}"
);
let e =
scanned_registries(&[rule("nope", PushScan::Off)], &["oci"], None, &[]).unwrap_err();
assert!(e.contains("[holger-oci-2]"), "{e}");
let e = scanned_registries(&[], &["up"], None, &["up"]).unwrap_err();
assert!(e.contains("[holger-oci-2]"), "{e}");
}
#[test]
fn attachments_and_digests_are_staged_and_a_tag_is_taken_in() {
assert!(is_attachment(
&format!("sha256-{}.sig", "a".repeat(64)),
b"{}"
));
assert!(is_attachment("x", br#"{"subject":{"digest":"sha256:x"}}"#));
assert!(!is_attachment("1.0", br#"{"layers":[]}"#));
// P7: a runnable image or an oversized blob is no attachment, whatever its subject.
assert!(!is_attachment(
"x",
br#"{"subject":{"digest":"sha256:x"},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":10}]}"#
));
assert!(!is_attachment(
"x",
br#"{"subject":{"digest":"sha256:x"},"layers":[{"mediaType":"application/vnd.dsse.envelope.v1+json","size":99999999}]}"#
));
assert!(!is_attachment(
"x",
br#"{"subject":{"digest":"sha256:x"},"layers":[{"mediaType":"application/vnd.dsse.envelope.v1+json"}]}"#
));
assert_eq!(
push_id("oci", "demo/app", "sha256:ab").name,
"oci/demo/app/sha256-ab.oci.tar"
);
}
}