holger-server-lib 0.6.9

Holger server library: config, wiring, gRPC service, Rust API
//! Remote-cache **upstreams** for the server request path — holger's TOP-LEVEL
//! federation FEATURE.
//!
//! An upstream is a read-only [`RepositoryBackendTrait`] that fetches an artifact
//! from an external registry (Artifactory, Nexus, Azure Artifacts, AWS
//! CodeArtifact) so a writable primary can write-through-cache it via
//! [`ProxyBackend`](crate::proxy::ProxyBackend). holger owns the *orchestration*
//! — which repos proxy which upstreams, pull-through caching, dedup-on-store, and
//! compression via znippy — but NOT the registry-client *mechanism*: the protocol
//! clients (auth, list, fetch, mirror) live in **Skidbladnir's** `federation`
//! module (the reusable "ship" that sails between the registry/cloud harbours).
//!
//! This whole module is gated behind the `federation` feature (holger's `full`
//! tier). holger's minimal install (`mini` / `embedded`) does NOT compile it and
//! does NOT link Skidbladnir or any registry-client dependency — the smallest
//! holger stays clean by construction. See `.nornir/holger-tiers.md`
//! (Deployment modes) and `Skidbladnir/.nornir/upstream-registry-federation.md`.

use std::sync::Arc;

use skidbladnir::federation::{
    ArtifactCoord, ArtifactoryRegistry, AwsCodeArtifactRegistry, AzureArtifactsRegistry,
    NexusRegistry, RegistryFormat, UpstreamRegistry,
};
use traits::{
    ArtifactFormat, ArtifactId, ArtifactMeta, RemoteUpstream, RepositoryBackendTrait, UpstreamAuth,
};

/// Map holger's config-layer [`ArtifactFormat`] to Skidbladnir's harbour-neutral
/// [`RegistryFormat`] (the ship's layout enum). Ecosystems the ship has no
/// dedicated layout for fall back to `Raw`.
fn to_registry_format(fmt: &ArtifactFormat) -> RegistryFormat {
    match fmt {
        ArtifactFormat::Rust => RegistryFormat::Rust,
        ArtifactFormat::Maven3 => RegistryFormat::Maven,
        ArtifactFormat::Pip => RegistryFormat::Pip,
        ArtifactFormat::Npm => RegistryFormat::Npm,
        ArtifactFormat::Nuget => RegistryFormat::Nuget,
        ArtifactFormat::Gem => RegistryFormat::Gem,
        _ => RegistryFormat::Raw,
    }
}

/// Map holger's config-layer [`UpstreamAuth`] to Skidbladnir's auth enum. The two
/// are field-identical by design (holger keeps the config type; the ship keeps
/// the wire mechanism).
fn to_registry_auth(auth: &UpstreamAuth) -> skidbladnir::federation::UpstreamAuth {
    use skidbladnir::federation::UpstreamAuth as SUA;
    match auth {
        UpstreamAuth::None => SUA::None,
        UpstreamAuth::Basic { username, password } => {
            SUA::Basic { username: username.clone(), password: password.clone() }
        }
        UpstreamAuth::Bearer { token } => SUA::Bearer { token: token.clone() },
        UpstreamAuth::ApiKey { header, key } => {
            SUA::ApiKey { header: header.clone(), key: key.clone() }
        }
        UpstreamAuth::Mtls { cert_pem, key_pem } => {
            SUA::Mtls { cert_pem: cert_pem.clone(), key_pem: key_pem.clone() }
        }
    }
}

fn to_coord(id: &ArtifactId) -> ArtifactCoord {
    ArtifactCoord {
        namespace: id.namespace.clone(),
        name: id.name.clone(),
        version: id.version.clone(),
    }
}

/// Adapter: wraps ONE Skidbladnir [`UpstreamRegistry`] harbour and exposes it as a
/// holger [`RepositoryBackendTrait`] + [`RemoteUpstream`] so it drops straight
/// into the existing `ProxyBackend` pull-through cache — unchanged. All registry
/// I/O is delegated to the ship; this type only translates holger ↔ ship types
/// and keeps holger's config-side auth for the `RemoteUpstream::auth()` contract.
pub struct SkidbladnirUpstream {
    inner: Box<dyn UpstreamRegistry>,
    format: ArtifactFormat,
    auth: UpstreamAuth,
}

impl SkidbladnirUpstream {
    fn new(inner: Box<dyn UpstreamRegistry>, format: ArtifactFormat, auth: UpstreamAuth) -> Self {
        Self { inner, format, auth }
    }

    /// A read-only proxy to an Artifactory repository (harbour: JFrog Artifactory).
    pub fn artifactory(
        name: impl Into<String>,
        base_url: impl Into<String>,
        repo: impl Into<String>,
        auth: UpstreamAuth,
        format: ArtifactFormat,
    ) -> Self {
        let inner = ArtifactoryRegistry::new(
            name,
            base_url,
            repo,
            to_registry_auth(&auth),
            to_registry_format(&format),
        );
        Self::new(Box::new(inner), format, auth)
    }

    /// A read-only proxy to a Nexus hosted/proxy repository (harbour: Sonatype Nexus).
    pub fn nexus(
        name: impl Into<String>,
        base_url: impl Into<String>,
        repo: impl Into<String>,
        auth: UpstreamAuth,
        format: ArtifactFormat,
    ) -> Self {
        let inner = NexusRegistry::new(
            name,
            base_url,
            repo,
            to_registry_auth(&auth),
            to_registry_format(&format),
        );
        Self::new(Box::new(inner), format, auth)
    }

    /// A read-only proxy to an Azure Artifacts feed (harbour: Azure DevOps).
    /// `base_url` is the Azure DevOps organization name (e.g. `contoso`);
    /// `repo` is `"{project}/{feed}"` or just `"{feed}"` for an org-scoped feed.
    pub fn azure(
        name: impl Into<String>,
        organization: impl Into<String>,
        repo: impl Into<String>,
        auth: UpstreamAuth,
        format: ArtifactFormat,
    ) -> Self {
        let repo = repo.into();
        let (project, feed) = match repo.split_once('/') {
            Some((p, f)) => (Some(p.to_string()), f.to_string()),
            None => (None, repo),
        };
        let inner = AzureArtifactsRegistry::new(
            name,
            organization,
            project,
            feed,
            to_registry_auth(&auth),
            to_registry_format(&format),
        );
        Self::new(Box::new(inner), format, auth)
    }

    /// A read-only proxy to an AWS CodeArtifact repository (harbour: AWS
    /// CodeArtifact). `base_url` is the full repository endpoint returned by
    /// `aws codeartifact get-repository-endpoint`; the bearer authorization token
    /// (from `get-authorization-token`) is carried in `auth`
    /// ([`UpstreamAuth::Bearer`]).
    pub fn aws(
        name: impl Into<String>,
        endpoint: impl Into<String>,
        auth: UpstreamAuth,
        format: ArtifactFormat,
    ) -> Self {
        // CodeArtifact package endpoints use a bearer authorization token; pull it
        // out of the configured auth (or empty — an anonymous/misconfigured repo
        // then simply 401s upstream, surfaced as an error, never a silent pass).
        let token = match &auth {
            UpstreamAuth::Bearer { token } => token.clone(),
            UpstreamAuth::Basic { password, .. } => password.clone(),
            _ => String::new(),
        };
        let inner = AwsCodeArtifactRegistry::new(name, endpoint, token, to_registry_format(&format));
        Self::new(Box::new(inner), format, auth)
    }

    /// Mirror a set of artifact coordinates out of the harbour, handing each
    /// fetched artifact's bytes to `sink` (holger's orchestration writes them
    /// through into a znippy-backed primary). Returns the number mirrored. This
    /// is holger hiring the ship for a voyage.
    pub fn mirror_into(
        &self,
        ids: &[ArtifactId],
        sink: &mut dyn FnMut(&ArtifactId, Vec<u8>) -> anyhow::Result<()>,
    ) -> anyhow::Result<usize> {
        let coords: Vec<ArtifactCoord> = ids.iter().map(to_coord).collect();
        // Re-associate each coordinate with its originating holger ArtifactId by
        // index (coords preserve order 1:1 with ids).
        let mut mirrored = 0usize;
        for (id, coord) in ids.iter().zip(coords.iter()) {
            if let Some(bytes) = self.inner.fetch(coord)? {
                sink(id, bytes)?;
                mirrored += 1;
            }
        }
        Ok(mirrored)
    }
}

impl RepositoryBackendTrait for SkidbladnirUpstream {
    fn name(&self) -> &str {
        self.inner.name()
    }

    fn format(&self) -> ArtifactFormat {
        self.format.clone()
    }

    fn is_writable(&self) -> bool {
        false
    }

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        self.inner.fetch(&to_coord(id))
    }

    fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
        anyhow::bail!("{}: remote upstream is read-only", self.inner.name())
    }

    fn handle_http2_request(
        &self,
        _method: &str,
        suburl: &str,
        _body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        // Cargo sparse crate-download shape: /{repo}/crates/{name}/{version}/download.
        // Other layouts are served by the primary's HTTP handler + this upstream's
        // fetch() (the gRPC + pull-through path); unknown shapes are a clean 404.
        let parts: Vec<&str> = suburl.trim_start_matches('/').split('/').collect();
        match parts.as_slice() {
            [_repo, "crates", name, version, "download"] => {
                let id = ArtifactId {
                    namespace: None,
                    name: (*name).to_string(),
                    version: (*version).to_string(),
                };
                match self.fetch(&id)? {
                    Some(body) => Ok((
                        200,
                        vec![("Content-Type".into(), "application/octet-stream".into())],
                        body,
                    )),
                    None => Ok((404, Vec::new(), b"Not found upstream".to_vec())),
                }
            }
            _ => Ok((404, Vec::new(), b"Not found".to_vec())),
        }
    }
}

impl RemoteUpstream for SkidbladnirUpstream {
    fn base_url(&self) -> &str {
        self.inner.base_url()
    }

    fn auth(&self) -> &UpstreamAuth {
        &self.auth
    }

    fn metadata(&self, id: &ArtifactId) -> anyhow::Result<Option<ArtifactMeta>> {
        Ok(self.inner.metadata(&to_coord(id))?.map(|m| ArtifactMeta {
            size: m.size,
            sha256: m.sha256,
            content_type: m.content_type,
        }))
    }
}

/// Convenience: box a [`SkidbladnirUpstream`] as an `Arc<dyn RepositoryBackendTrait>`
/// for the wiring engine.
pub fn as_backend(up: SkidbladnirUpstream) -> Arc<dyn RepositoryBackendTrait> {
    Arc::new(up)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The holger→ship auth translation is total and field-preserving (so a
    /// configured credential reaches the harbour unchanged).
    #[test]
    fn auth_translation_is_field_preserving() {
        use skidbladnir::federation::UpstreamAuth as SUA;
        let cases = [
            UpstreamAuth::None,
            UpstreamAuth::Basic { username: "u".into(), password: "p".into() },
            UpstreamAuth::Bearer { token: "t".into() },
            UpstreamAuth::ApiKey { header: "X-Api".into(), key: "k".into() },
        ];
        for c in cases {
            let mapped = to_registry_auth(&c);
            match (&c, &mapped) {
                (UpstreamAuth::None, SUA::None) => {}
                (UpstreamAuth::Basic { username, password }, SUA::Basic { username: u2, password: p2 }) => {
                    assert_eq!((username, password), (u2, p2));
                }
                (UpstreamAuth::Bearer { token }, SUA::Bearer { token: t2 }) => assert_eq!(token, t2),
                (UpstreamAuth::ApiKey { header, key }, SUA::ApiKey { header: h2, key: k2 }) => {
                    assert_eq!((header, key), (h2, k2));
                }
                _ => panic!("auth variant mistranslated"),
            }
        }
    }

    /// Format translation covers the mapped ecosystems and falls back to `Raw`.
    #[test]
    fn format_translation_maps_and_falls_back() {
        assert_eq!(to_registry_format(&ArtifactFormat::Rust), RegistryFormat::Rust);
        assert_eq!(to_registry_format(&ArtifactFormat::Maven3), RegistryFormat::Maven);
        assert_eq!(to_registry_format(&ArtifactFormat::Docker), RegistryFormat::Raw);
    }

    /// The adapter is a read-only backend (put is rejected) and reports the
    /// wrapped harbour's identity + base URL.
    #[test]
    fn adapter_is_read_only_and_delegates_identity() {
        let up = SkidbladnirUpstream::nexus(
            "nx", "https://nexus.corp", "crates-proxy", UpstreamAuth::None, ArtifactFormat::Rust,
        );
        assert_eq!(RepositoryBackendTrait::name(&up), "nx");
        assert!(!up.is_writable());
        assert_eq!(up.base_url(), "https://nexus.corp");
        let id = ArtifactId { namespace: None, name: "x".into(), version: "1".into() };
        assert!(up.put(&id, b"d").is_err(), "remote upstream must reject writes");
    }

    /// Azure `repo` field splits into project/feed (project-scoped) or feed-only
    /// (org-scoped) and the wrapped harbour builds the right base origin.
    #[test]
    fn azure_repo_splits_project_and_feed() {
        let scoped = SkidbladnirUpstream::azure(
            "az", "contoso", "proj/myfeed", UpstreamAuth::None, ArtifactFormat::Npm,
        );
        assert_eq!(scoped.base_url(), "https://pkgs.dev.azure.com/contoso");
    }
}