portaki-cli 10.12.0

Portaki module CLI (portaki) — init, build, test, hosted sandbox, OCI publish and registry announcement
//! OCI artifact packaging and push to Portaki's OCI repository (ORAS-compatible layout).
//!
//! The repository is the platform's, and only that one: the Portaki registry issues a short push
//! grant, scoped to the module and the version (`POST /registry/v1/publications/push-token`), and
//! names the OCI host in its answer. No host is written down here, and no credential is read from
//! the machine.

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;

/// The right to push one version, as the Portaki registry hands it out.
///
/// `password` is a fifteen-minute secret: it is never displayed, it is only written into the
/// ephemeral `DOCKER_CONFIG` that cosign reads, and it never appears on the command line.
#[derive(Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushGrant {
    /// `oci.portaki.app` — the OCI host, never guessed by the CLI.
    pub registry: String,
    /// `modules/<id>`
    pub repository: String,
    /// `oci.portaki.app/modules/<id>:<version>`
    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 {
    /// Refuses a grant that does not name this module and this version: the CLI does not push
    /// under any name other than the one it asked for, even when it is offered one.
    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())
    }
}

/// What a push leaves behind.
///
/// The digest is read back from the OCI repository rather than inferred from the manifest URL:
/// it is what identifies a publication at the Portaki registry (ADR-0005).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushedArtifact {
    /// `oci.portaki.app`
    pub registry: String,
    /// `modules/nuki`
    pub repository: String,
    /// `sha256:…`
    pub digest: String,
}

impl PushedArtifact {
    /// `oci.portaki.app/modules/nuki@sha256:…` — what cosign signs: the digest, never the tag.
    pub fn subject(&self) -> String {
        format!("{}/{}@{}", self.registry, self.repository, self.digest)
    }

    /// The shape `POST /registry/v1/publications` requires.
    pub fn artifact_ref(&self) -> String {
        format!("oci://{}", self.subject())
    }
}

/// A development repository (local compose) is spoken to over HTTP; every other one over HTTPS.
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")
}

/// Pushes the module artifact under the reference the push grant names.
///
/// Expects `portaki build` output under `artifact_dir`:
/// - `publish-manifest.json` (frozen catalog for OCI)
/// - `module_root/target/wasm32-unknown-unknown/release/*.wasm`
/// - `module_root/i18n/*.json` (optional)
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))?;

    // The tag, read back: what the registry re-reads at announcement, and what cosign will sign.
    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,
    })
}

/// The config every module artifact carries: empty, as ORAS writes it.
fn empty_config() -> Config {
    Config::new(
        br#"{}"#.to_vec(),
        "application/vnd.oci.empty.v1+json".to_string(),
        None,
    )
}

/// The manifest the OCI repository holds under `reference`, and its digest — read anonymously:
/// every module repository is public to pull, a refused push leaves no right to read with.
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}"))
}

/// Is `published` the manifest a push of `layers` writes? Its config and its layers — media
/// type and digest, in order — are all a manifest without annotations holds: equal, the two
/// manifests are the same bytes, so the same digest. Compared on content rather than by
/// re-serialising the manifest, which would tie the answer to the library's JSON formatter.
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}");
    }

    /// What `push` writes for these layers, built by the library itself.
    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"));
    }
}