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");
}
}