pub mod pack;
use std::path::Path;
use anyhow::{Context, Result};
use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
use oci_distribution::manifest::OciImageManifest;
use oci_distribution::secrets::RegistryAuth;
use oci_distribution::Reference;
#[derive(Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushGrant {
pub registry: String,
pub repository: String,
pub reference: String,
pub username: String,
pub password: String,
}
impl std::fmt::Debug for PushGrant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PushGrant")
.field("reference", &self.reference)
.field("password", &"<redacted>")
.finish()
}
}
impl PushGrant {
pub fn check(&self, coords: &pack::ModuleCoordinates) -> Result<()> {
let repository = format!("modules/{}", coords.id);
let reference = format!("{}/{repository}:{}", self.registry, coords.version);
if self.repository != repository || self.reference != reference {
anyhow::bail!(
"the registry granted a push to {}, not to {reference} — refusing to push",
self.reference
);
}
Ok(())
}
fn auth(&self) -> RegistryAuth {
RegistryAuth::Basic(self.username.clone(), self.password.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushedArtifact {
pub registry: String,
pub repository: String,
pub digest: String,
}
impl PushedArtifact {
pub fn subject(&self) -> String {
format!("{}/{}@{}", self.registry, self.repository, self.digest)
}
pub fn artifact_ref(&self) -> String {
format!("oci://{}", self.subject())
}
}
pub fn is_local(registry: &str) -> bool {
let host = registry.rsplit_once(':').map_or(registry, |(host, _)| host);
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost")
}
pub async fn push_artifact(
module_root: &Path,
artifact_dir: &Path,
grant: &PushGrant,
) -> Result<PushedArtifact> {
let coords = pack::read_module_coordinates(module_root, artifact_dir)?;
grant.check(&coords)?;
let layers = pack::collect_push_layers(module_root, artifact_dir)?;
let reference: Reference = grant
.reference
.parse()
.with_context(|| format!("invalid OCI reference: {}", grant.reference))?;
let image_layers = pack::layers_to_image_layers(&layers)?;
let config = empty_config();
let client = client(&grant.registry);
let auth = grant.auth();
client
.push(&reference, &image_layers, config, &auth, None)
.await
.with_context(|| format!("push to {}", grant.reference))?;
let digest = client
.fetch_manifest_digest(&reference, &auth)
.await
.context("read back the pushed manifest digest")?;
Ok(PushedArtifact {
registry: grant.registry.clone(),
repository: grant.repository.clone(),
digest,
})
}
fn empty_config() -> Config {
Config::new(
br#"{}"#.to_vec(),
"application/vnd.oci.empty.v1+json".to_string(),
None,
)
}
pub async fn published_manifest(
registry: &str,
reference: &str,
) -> Result<(OciImageManifest, String)> {
let parsed: Reference = reference
.parse()
.with_context(|| format!("invalid OCI reference: {reference}"))?;
client(registry)
.pull_image_manifest(&parsed, &RegistryAuth::Anonymous)
.await
.with_context(|| format!("read {reference}"))
}
pub fn same_content(published: &OciImageManifest, layers: &[ImageLayer]) -> bool {
let config = empty_config();
published.config.digest == config.sha256_digest()
&& published.config.media_type == config.media_type
&& published.layers.len() == layers.len()
&& published.layers.iter().zip(layers).all(|(theirs, ours)| {
theirs.media_type == ours.media_type && theirs.digest == ours.sha256_digest()
})
}
fn client(registry: &str) -> Client {
let protocol = if is_local(registry) {
ClientProtocol::HttpsExcept(vec![registry.to_string()])
} else {
ClientProtocol::Https
};
Client::new(ClientConfig {
protocol,
..ClientConfig::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn grant(reference: &str, repository: &str) -> PushGrant {
PushGrant {
registry: "oci-staging.portaki.app".to_string(),
repository: repository.to_string(),
reference: reference.to_string(),
username: "portaki-push".to_string(),
password: "secret".to_string(),
}
}
fn nuki() -> pack::ModuleCoordinates {
pack::ModuleCoordinates {
id: "nuki".to_string(),
version: "1.4.0".to_string(),
}
}
#[test]
fn the_announced_reference_pins_the_digest_in_the_modules_repository() {
let pushed = PushedArtifact {
registry: "oci-staging.portaki.app".to_string(),
repository: "modules/nuki".to_string(),
digest: "sha256:9f2c".to_string(),
};
assert_eq!(
pushed.subject(),
"oci-staging.portaki.app/modules/nuki@sha256:9f2c"
);
assert_eq!(
pushed.artifact_ref(),
"oci://oci-staging.portaki.app/modules/nuki@sha256:9f2c"
);
}
#[test]
fn a_grant_for_another_module_or_version_is_refused() {
let good = grant("oci-staging.portaki.app/modules/nuki:1.4.0", "modules/nuki");
assert!(good.check(&nuki()).is_ok());
for (reference, repository) in [
(
"oci-staging.portaki.app/modules/other:1.4.0",
"modules/other",
),
("oci-staging.portaki.app/modules/nuki:1.5.0", "modules/nuki"),
("evil.example/modules/nuki:1.4.0", "modules/nuki"),
] {
let error = grant(reference, repository)
.check(&nuki())
.unwrap_err()
.to_string();
assert!(error.contains("refusing to push"), "{reference}: {error}");
}
}
#[test]
fn the_password_never_shows_in_debug_output() {
let shown = format!("{:?}", grant("r", "modules/nuki"));
assert!(!shown.contains("secret"), "{shown}");
}
fn pushed_manifest(layers: &[ImageLayer]) -> OciImageManifest {
OciImageManifest::build(layers, &empty_config(), None)
}
fn layer(data: &[u8], media_type: &str) -> ImageLayer {
ImageLayer::new(data.to_vec(), media_type.to_string(), None)
}
#[test]
fn the_same_layers_make_the_same_artifact() {
let built = [
layer(b"\0asm", "application/vnd.portaki.module.wasm"),
layer(b"{}", "application/vnd.portaki.manifest+json"),
];
assert!(same_content(&pushed_manifest(&built), &built));
}
#[test]
fn another_byte_another_order_or_another_layer_is_another_artifact() {
let wasm = layer(b"\0asm", "application/vnd.portaki.module.wasm");
let manifest = layer(b"{}", "application/vnd.portaki.manifest+json");
let published = pushed_manifest(&[wasm.clone(), manifest.clone()]);
let rebuilt = layer(b"\0asm2", "application/vnd.portaki.module.wasm");
assert!(!same_content(&published, &[rebuilt, manifest.clone()]));
assert!(!same_content(&published, &[manifest.clone(), wasm.clone()]));
assert!(!same_content(&published, std::slice::from_ref(&wasm)));
let retyped = layer(b"{}", "application/json");
assert!(!same_content(&published, &[wasm, retyped]));
}
#[test]
fn only_a_development_host_is_spoken_to_in_http() {
assert!(is_local("oci.localhost:8080"));
assert!(is_local("localhost:5000"));
assert!(is_local("127.0.0.1:5000"));
assert!(!is_local("oci-staging.portaki.app"));
assert!(!is_local("localhost.evil.example"));
}
}