use crate::command::{CommandRequest, CommandResult, CommandRunner};
use crate::domain::{ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) const OCI_REGISTRY_PUBLISHER: &str = "oci_registry";
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ImageConfigMetadata {
pub config_digest: String,
pub platform: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct RemoteImageMetadata {
pub manifest_digest: String,
pub config: ImageConfigMetadata,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum PreparedImageAction {
Existing {
manifest_digest: String,
},
Reuse {
source_reference: String,
manifest_digest: String,
},
Build {
local_reference: String,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PreparedOciImage {
pub schema_version: u32,
pub target: String,
pub repository: String,
pub tag: String,
pub platform: String,
pub config_digest: String,
pub action: PreparedImageAction,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct PreparedOciOutput {
pub reference: String,
pub config_digest: String,
}
impl PreparedOciImage {
pub(crate) fn current_reference(&self) -> String {
format!("{}:{}", self.repository, self.tag)
}
pub(crate) fn output(&self) -> PreparedOciOutput {
let reference = match &self.action {
PreparedImageAction::Existing { manifest_digest }
| PreparedImageAction::Reuse {
manifest_digest, ..
} => canonical_reference(&self.repository, manifest_digest),
PreparedImageAction::Build { local_reference } => local_reference.clone(),
};
PreparedOciOutput {
reference,
config_digest: self.config_digest.clone(),
}
}
pub(crate) fn write(
&self,
identity: &ArtifactIdentity,
staging: &Path,
) -> Result<PreparedArtifact> {
fs::create_dir_all(staging)
.with_context(|| format!("failed to create {}", staging.display()))?;
let bytes = json_bytes(self)?;
let path = staging.join("image.oci.json");
fs::write(&path, &bytes).with_context(|| format!("failed to write {}", path.display()))?;
Ok(PreparedArtifact {
identity: identity.clone(),
sha256: sha256_bytes(&bytes),
path,
})
}
pub(crate) fn read(manifest: &ArtifactManifest) -> Result<Self> {
if manifest.publisher != OCI_REGISTRY_PUBLISHER || manifest.artifacts.len() != 1 {
bail!("OCI artifact manifest must contain exactly one OCI image");
}
let artifact = &manifest.artifacts[0];
let bytes = fs::read(&artifact.path).with_context(|| {
format!(
"failed to read prepared OCI metadata {}",
artifact.path.display()
)
})?;
let actual = sha256_bytes(&bytes);
if actual != artifact.sha256 {
bail!(
"prepared OCI metadata changed after Prepare: {} (expected {}, found {actual})",
artifact.path.display(),
artifact.sha256
);
}
let prepared: Self = serde_json::from_slice(&bytes).with_context(|| {
format!("invalid prepared OCI metadata {}", artifact.path.display())
})?;
prepared.validate(manifest, &artifact.identity)?;
Ok(prepared)
}
fn validate(&self, manifest: &ArtifactManifest, identity: &ArtifactIdentity) -> Result<()> {
if self.schema_version != 1 || self.target != manifest.target {
bail!("prepared OCI metadata identity differs from its manifest");
}
let expected = ArtifactIdentity::OciImage {
repository: self.repository.clone(),
tag: self.tag.clone(),
platform: self.platform.clone(),
};
if *identity != expected || self.tag != manifest.release.tag.to_string() {
bail!("prepared OCI image identity differs from its ReleasePlan");
}
validate_repository(&self.repository)?;
validate_tag(&self.tag)?;
validate_platform(&self.platform)?;
validate_digest("OCI config digest", &self.config_digest)?;
match &self.action {
PreparedImageAction::Existing { manifest_digest } => {
validate_digest("OCI manifest digest", manifest_digest)?;
}
PreparedImageAction::Reuse {
source_reference,
manifest_digest,
} => {
validate_digest("OCI manifest digest", manifest_digest)?;
let (repository, reference_digest) = exact_reference(source_reference)?;
if repository != self.repository || reference_digest != *manifest_digest {
bail!("prepared OCI reuse reference differs from its manifest identity");
}
}
PreparedImageAction::Build { local_reference } => {
if *local_reference != self.current_reference() {
bail!("prepared OCI local reference differs from its destination");
}
}
}
Ok(())
}
}
pub(crate) struct OciClient {
root: PathBuf,
runner: Arc<dyn CommandRunner>,
}
impl OciClient {
pub(crate) fn new(root: impl Into<PathBuf>, runner: Arc<dyn CommandRunner>) -> Self {
Self {
root: root.into(),
runner,
}
}
pub(crate) fn remote_image(&self, reference: &str) -> Result<Option<RemoteImageMetadata>> {
let manifest_request = CommandRequest::new(
"docker",
[
"buildx",
"imagetools",
"inspect",
reference,
"--format",
"{{json .Manifest}}",
],
&self.root,
);
let manifest_result = self.runner.execute(&manifest_request)?;
if manifest_result.status != 0 {
if is_missing_image_error(&manifest_result.stderr) {
return Ok(None);
}
return Err(command_error(
manifest_result,
&format!("inspect OCI image manifest `{reference}`"),
));
}
let manifest: serde_json::Value = serde_json::from_str(&manifest_result.stdout)
.with_context(|| format!("invalid OCI manifest inspection for `{reference}`"))?;
let manifest_digest = json_string(&manifest, &["digest"])?;
validate_digest("OCI manifest digest", &manifest_digest)?;
let name = reference
.rsplit_once('@')
.map_or(reference, |(name, _)| name);
let pinned_reference = format!("{name}@{manifest_digest}");
let raw_request = CommandRequest::new(
"docker",
[
"buildx",
"imagetools",
"inspect",
pinned_reference.as_str(),
"--raw",
],
&self.root,
);
let raw_result = self
.runner
.execute(&raw_request)?
.require_success(&format!("inspect raw OCI manifest `{reference}`"))?;
let raw: serde_json::Value = serde_json::from_str(&raw_result.stdout)
.with_context(|| format!("invalid raw OCI manifest for `{reference}`"))?;
let observed_manifest_digest =
format!("sha256:{}", sha256_bytes(raw_result.stdout.as_bytes()));
if observed_manifest_digest != manifest_digest {
bail!(
"raw OCI manifest for `{reference}` hashes to {observed_manifest_digest}, expected {manifest_digest}"
);
}
let config_digest = json_string(&raw, &["config", "digest"])?;
validate_digest("OCI config digest", &config_digest)?;
let image_request = CommandRequest::new(
"docker",
[
"buildx",
"imagetools",
"inspect",
pinned_reference.as_str(),
"--format",
"{{json .Image}}",
],
&self.root,
);
let image_result = self
.runner
.execute(&image_request)?
.require_success(&format!("inspect OCI image config `{reference}`"))?;
let image: serde_json::Value = serde_json::from_str(&image_result.stdout)
.with_context(|| format!("invalid OCI image inspection for `{reference}`"))?;
let config = image_config_metadata(&image, config_digest, false)?;
Ok(Some(RemoteImageMetadata {
manifest_digest,
config,
}))
}
pub(crate) fn local_image(&self, reference: &str) -> Result<ImageConfigMetadata> {
let request = CommandRequest::new(
"docker",
["image", "inspect", "--format", "{{json .}}", reference],
&self.root,
);
let result = self
.runner
.execute(&request)?
.require_success(&format!("inspect local OCI image `{reference}`"))?;
let image: serde_json::Value = serde_json::from_str(&result.stdout)
.with_context(|| format!("invalid local OCI image inspection for `{reference}`"))?;
let config_digest = json_string(&image, &["Id"])?;
image_config_metadata(&image, config_digest, true)
}
}
pub(crate) fn validate_image_template(image: &str, platform: &str) -> Result<()> {
if image.matches("{version}").count() != 1 {
bail!("OCI image template must contain exactly one `{{version}}`: `{image}`");
}
validate_platform(platform)?;
let version = "2000.01.01.1";
let (repository, tag, _) = image_reference(image, version)?;
validate_repository(&repository)?;
if tag != version {
bail!("OCI image template tag must be exactly `{{version}}`: `{image}`");
}
Ok(())
}
pub(crate) fn image_identity(
image: &str,
platform: &str,
release: &ReleaseCandidate,
) -> Result<ArtifactIdentity> {
let (repository, tag, _) = image_reference(image, &release.tag.to_string())?;
Ok(ArtifactIdentity::OciImage {
repository,
tag,
platform: platform.to_owned(),
})
}
pub(crate) fn image_reference(image: &str, version: &str) -> Result<(String, String, String)> {
let expanded = image.replace("{version}", version);
if expanded.contains('{') || expanded.contains('}') {
bail!("OCI image template contains an unsupported placeholder: `{image}`");
}
let (repository, tag) = split_tagged_reference(&expanded)?;
if tag != version {
bail!("OCI image template tag must resolve to release version {version}: `{image}`");
}
Ok((repository.to_owned(), tag.to_owned(), expanded))
}
pub(crate) fn canonical_reference(repository: &str, manifest_digest: &str) -> String {
format!("{repository}@{manifest_digest}")
}
pub(crate) fn verify_remote(
prepared: &PreparedOciImage,
remote: &RemoteImageMetadata,
) -> Result<()> {
if remote.config.config_digest != prepared.config_digest
|| remote.config.platform != prepared.platform
{
bail!(
"published OCI image `{}` config/platform differs from Prepare",
prepared.target
);
}
match &prepared.action {
PreparedImageAction::Existing { manifest_digest }
| PreparedImageAction::Reuse {
manifest_digest, ..
} if remote.manifest_digest != *manifest_digest => bail!(
"published OCI image `{}` manifest is {}, expected {manifest_digest}",
prepared.target,
remote.manifest_digest
),
_ => Ok(()),
}
}
pub(crate) fn verify_local(prepared: &PreparedOciImage, local: &ImageConfigMetadata) -> Result<()> {
if local.config_digest != prepared.config_digest || local.platform != prepared.platform {
bail!(
"local OCI image `{}` config/platform changed after Prepare",
prepared.target
);
}
Ok(())
}
pub(crate) fn command_error(result: CommandResult, description: &str) -> anyhow::Error {
result
.require_success(description)
.expect_err("non-zero command result must fail")
}
fn split_tagged_reference(reference: &str) -> Result<(&str, &str)> {
if reference.contains('@') || reference.chars().any(char::is_whitespace) {
bail!("OCI image must be a tagged reference, not a digest: `{reference}`");
}
let separator = reference
.rfind(':')
.filter(|index| *index > reference.rfind('/').unwrap_or(0))
.with_context(|| format!("OCI image must include a tag: `{reference}`"))?;
let (repository, tag_with_separator) = reference.split_at(separator);
let tag = &tag_with_separator[1..];
validate_repository(repository)?;
validate_tag(tag)?;
Ok((repository, tag))
}
fn exact_reference(reference: &str) -> Result<(String, String)> {
let (name, digest) = reference
.rsplit_once('@')
.with_context(|| format!("OCI reference must contain an exact digest: `{reference}`"))?;
let repository = name
.rsplit_once(':')
.filter(|(repository, _)| repository.contains('/'))
.map_or(name, |(repository, _)| repository);
validate_repository(repository)?;
validate_digest("OCI manifest digest", digest)?;
Ok((repository.to_owned(), digest.to_owned()))
}
fn image_config_metadata(
image: &serde_json::Value,
config_digest: String,
local: bool,
) -> Result<ImageConfigMetadata> {
validate_digest("OCI config digest", &config_digest)?;
let (os_key, architecture_key) = if local {
("Os", "Architecture")
} else {
("os", "architecture")
};
let os = json_string(image, &[os_key])?;
let architecture = json_string(image, &[architecture_key])?;
let platform = format!("{os}/{architecture}");
validate_platform(&platform)?;
Ok(ImageConfigMetadata {
config_digest,
platform,
})
}
fn validate_repository(repository: &str) -> Result<()> {
let last_segment = repository.rsplit('/').next().unwrap_or(repository);
if repository.is_empty()
|| repository.contains('@')
|| repository.chars().any(char::is_whitespace)
|| !repository.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'.' | b'_' | b'-' | b'/' | b':')
})
|| repository.starts_with('/')
|| repository.ends_with('/')
|| repository.contains("//")
|| last_segment.contains(':')
|| !repository.contains('/')
{
bail!("invalid OCI repository `{repository}`");
}
Ok(())
}
fn validate_tag(tag: &str) -> Result<()> {
if tag.is_empty() || tag.contains(['/', ':', '@']) || tag.chars().any(char::is_whitespace) {
bail!("invalid OCI tag `{tag}`");
}
Ok(())
}
fn validate_platform(platform: &str) -> Result<()> {
let Some((os, architecture)) = platform.split_once('/') else {
bail!("OCI platform must use os/architecture: `{platform}`");
};
if os.is_empty()
|| architecture.is_empty()
|| os.contains('/')
|| architecture.contains('/')
|| platform.chars().any(char::is_whitespace)
{
bail!("invalid OCI platform `{platform}`");
}
Ok(())
}
fn validate_digest(description: &str, digest: &str) -> Result<()> {
let Some(hex) = digest.strip_prefix("sha256:") else {
bail!("{description} must use sha256: `{digest}`");
};
if hex.len() != 64
|| !hex
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
bail!("invalid {description}: `{digest}`");
}
Ok(())
}
fn json_string(value: &serde_json::Value, path: &[&str]) -> Result<String> {
let mut current = value;
for key in path {
current = current
.get(*key)
.with_context(|| format!("OCI inspection JSON is missing `{}`", path.join(".")))?;
}
current
.as_str()
.map(str::to_owned)
.with_context(|| format!("OCI inspection JSON `{}` is not a string", path.join(".")))
}
fn is_missing_image_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("not found")
|| lower.contains("manifest unknown")
|| lower.contains("name unknown")
}
fn json_bytes(value: &impl Serialize) -> Result<Vec<u8>> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
Ok(bytes)
}
fn sha256_bytes(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}