pub mod pack;
use std::path::Path;
use anyhow::{Context, Result};
use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config};
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 = Config::new(
br#"{}"#.to_vec(),
"application/vnd.oci.empty.v1+json".to_string(),
None,
);
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 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}");
}
#[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"));
}
}