Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

src/federation/artifactory.rs

//! **JFrog Artifactory** harbour.
//!
//! Serves content at `{base}/artifactory/{repo}/{layout-path}`, where the layout
//! path is the per-ecosystem mapping in [`upstream_path`]. Auth may be Basic,
//! Bearer, or the JFrog API-key header (`X-JFrog-Art-Api-Key`) via the shared
//! [`UpstreamAuth`].

#[cfg(feature = "federation")]
use anyhow::Result;

use super::{ArtifactCoord, RegistryFormat, UpstreamAuth, upstream_path};
#[cfg(feature = "federation")]
use super::{ArtifactMeta, UpstreamRegistry, verify_sha256};
#[cfg(feature = "federation")]
use super::{get_bytes, head_meta};

/// A read-only harbour proxying one Artifactory repository.
#[cfg_attr(not(feature = "federation"), allow(dead_code))]
pub struct ArtifactoryRegistry {
    name: String,
    /// Artifactory base URL, e.g. `https://jfrog.corp`.
    base_url: String,
    /// The repository name, e.g. `maven-remote` or `cargo-remote`.
    repo: String,
    auth: UpstreamAuth,
    format: RegistryFormat,
}

impl ArtifactoryRegistry {
    pub fn new(
        name: impl Into<String>,
        base_url: impl Into<String>,
        repo: impl Into<String>,
        auth: UpstreamAuth,
        format: RegistryFormat,
    ) -> Self {
        Self {
            name: name.into(),
            base_url: base_url.into().trim_end_matches('/').to_string(),
            repo: repo.into(),
            auth,
            format,
        }
    }

    /// Content URL for one artifact: `{base}/artifactory/{repo}/{layout-path}`.
    pub fn content_url(&self, id: &ArtifactCoord) -> String {
        format!(
            "{}/artifactory/{}/{}",
            self.base_url,
            self.repo,
            upstream_path(self.format, id)
        )
    }
}

#[cfg(feature = "federation")]
impl UpstreamRegistry for ArtifactoryRegistry {
    fn name(&self) -> &str {
        &self.name
    }
    fn base_url(&self) -> &str {
        &self.base_url
    }
    fn auth(&self) -> &UpstreamAuth {
        &self.auth
    }
    fn format(&self) -> RegistryFormat {
        self.format
    }

    fn fetch(&self, id: &ArtifactCoord) -> Result<Option<Vec<u8>>> {
        let (bytes, sha256) = match get_bytes(&self.base_url, &self.content_url(id), &self.auth)? {
            Some(b) => b,
            None => return Ok(None),
        };
        if let Some(expected) = sha256 {
            verify_sha256(&bytes, &expected)?;
        }
        Ok(Some(bytes))
    }

    fn metadata(&self, id: &ArtifactCoord) -> Result<Option<ArtifactMeta>> {
        head_meta(&self.base_url, &self.content_url(id), &self.auth)
    }
}

#[cfg(test)]
mod tests {
    #[cfg(feature = "federation")]
    use super::super::testutil::{MockResponse, MockServer};
    use super::*;

    #[cfg_attr(not(feature = "federation"), allow(dead_code))]
    fn id(name: &str, version: &str) -> ArtifactCoord {
        ArtifactCoord::new(name, version)
    }

    #[test]
    fn content_url_uses_artifactory_layout_for_maven() {
        let up = ArtifactoryRegistry::new(
            "jf",
            "https://jfrog.corp/",
            "maven-remote",
            UpstreamAuth::None,
            RegistryFormat::Maven,
        );
        let mvn = ArtifactCoord::with_namespace("com.example", "lib", "2.1");
        assert_eq!(
            up.content_url(&mvn),
            "https://jfrog.corp/artifactory/maven-remote/com/example/lib/2.1/lib-2.1.jar"
        );
    }

    #[test]
    #[cfg(feature = "federation")]
    fn bearer_auth_header_is_sent() {
        let server = MockServer::start(|_m, _p| (200, b"jar-bytes".to_vec()));
        let up = ArtifactoryRegistry::new(
            "jf",
            &server.base_url,
            "maven-remote",
            UpstreamAuth::Bearer {
                token: "tok-123".into(),
            },
            RegistryFormat::Rust,
        );
        up.fetch(&id("serde", "1.0.0")).unwrap();
        let reqs = server.requests();
        assert_eq!(reqs[0].header("authorization"), Some("Bearer tok-123"));
        crate::functional_status(
            "skidbladnir/federation-artifactory",
            "bearer_auth_attached",
            reqs[0].header("authorization") == Some("Bearer tok-123"),
            "Artifactory Bearer token attached",
        );
    }

    #[test]
    #[cfg(feature = "federation")]
    fn api_key_header_is_sent() {
        let server = MockServer::start(|_m, _p| (200, b"bytes".to_vec()));
        let up = ArtifactoryRegistry::new(
            "jf",
            &server.base_url,
            "repo",
            UpstreamAuth::ApiKey {
                header: "X-JFrog-Art-Api-Key".into(),
                key: "AKC123".into(),
            },
            RegistryFormat::Rust,
        );
        up.fetch(&id("serde", "1.0.0")).unwrap();
        let reqs = server.requests();
        assert_eq!(reqs[0].header("x-jfrog-art-api-key"), Some("AKC123"));
    }

    #[test]
    #[cfg(feature = "federation")]
    fn upstream_404_is_clean_not_found() {
        let server = MockServer::start(|_m, _p| (404, b"no".to_vec()));
        let up = ArtifactoryRegistry::new(
            "jf",
            &server.base_url,
            "repo",
            UpstreamAuth::None,
            RegistryFormat::Rust,
        );
        assert!(up.fetch(&id("ghost", "9.9")).unwrap().is_none());
    }

    #[test]
    #[cfg(feature = "federation")]
    fn checksum_mismatch_is_rejected() {
        // No X-Checksum header → the happy path returns bytes (nothing to verify).
        let server = MockServer::start(|_m, _p| (200, b"real-bytes".to_vec()));
        let up = ArtifactoryRegistry::new(
            "jf",
            &server.base_url,
            "repo",
            UpstreamAuth::None,
            RegistryFormat::Rust,
        );
        assert!(up.fetch(&id("serde", "1.0.0")).unwrap().is_some());
    }

    /// End-to-end cache-poisoning guard (now testable via the header-emitting mock):
    /// the server advertises a `X-Checksum-Sha256` that does NOT match the body, so
    /// `fetch` — which verifies the body against the advertised checksum — hard-errors
    /// rather than returning poisoned bytes. RED-when-broken.
    #[test]
    #[cfg(feature = "federation")]
    fn fetch_rejects_advertised_checksum_mismatch() {
        let server = MockServer::start_with(|_req| {
            MockResponse::new(200, b"real-bytes".to_vec()).with_header(
                "X-Checksum-Sha256",
                // A valid-looking but WRONG digest (not the body's hash).
                "0000000000000000000000000000000000000000000000000000000000000000",
            )
        });
        let up = ArtifactoryRegistry::new(
            "jf",
            &server.base_url,
            "repo",
            UpstreamAuth::None,
            RegistryFormat::Rust,
        );
        let err = up.fetch(&id("serde", "1.0.0")).unwrap_err();
        assert!(
            err.to_string().contains("sha256 mismatch"),
            "poisoned body must be rejected: {err}"
        );
        crate::functional_status(
            "skidbladnir/federation-artifactory",
            "fetch_rejects_poisoned_checksum",
            err.to_string().contains("sha256 mismatch"),
            "advertised-checksum mismatch is a hard error end-to-end (cache-poisoning guard)",
        );
    }

    #[test]
    #[cfg(feature = "federation")]
    fn metadata_head_returns_size() {
        let server = MockServer::start(|_m, _p| (200, b"twelve-bytes".to_vec()));
        let up = ArtifactoryRegistry::new(
            "jf",
            &server.base_url,
            "repo",
            UpstreamAuth::None,
            RegistryFormat::Rust,
        );
        let meta = up
            .metadata(&id("serde", "1.0.0"))
            .unwrap()
            .expect("some meta");
        assert_eq!(meta.size, Some(b"twelve-bytes".len() as u64));
        assert_eq!(server.requests()[0].method, "HEAD");
    }
}