agent/connector/artifactory/src/lib.rs
//! JFrog Artifactory connector for the holger agent — the Artifactory twin of
//! `connector_nexus`, implementing the same [`ConnectorTrait`] so the agent's
//! migrate / suck / push operations work against Artifactory unchanged.
//!
//! API surface used (Artifactory REST + AQL):
//! - **list repositories** — `GET /artifactory/api/repositories`
//! (`{key, type, packageType, url}`).
//! - **list assets** — AQL `POST /artifactory/api/search/aql`,
//! `items.find({"repo":"<repo>"}).include("repo","path","name","size")`. Builds
//! each asset's download URL from the base + `repo/path/name`.
//! - **download** — `GET /artifactory/<repo>/<path>/<name>`.
//! - **upload (deploy)** — `PUT /artifactory/<repo>/<path>`.
//!
//! Auth mirrors the Nexus connector: HTTP Basic, OIDC/access-token Bearer, mTLS,
//! or None. The same SSRF / credential-exfiltration guard applies on download
//! (only same-origin URLs get credentials).
use anyhow::Result;
use async_trait::async_trait;
use traits::{ConnectorTrait, RemoteAsset, RemoteRepository};
/// Hard cap on a single artifact download (bytes) — mirrors the Nexus connector.
const MAX_DOWNLOAD_BYTES: u64 = 8 * 1024 * 1024 * 1024;
/// Hard cap on a metadata/JSON response body (repo + AQL listings).
const MAX_JSON_BYTES: u64 = 256 * 1024 * 1024;
/// Read a response body into memory, rejecting bodies larger than `max`.
async fn read_capped(resp: reqwest::Response, max: u64) -> Result<Vec<u8>> {
if let Some(len) = resp.content_length() {
if len > max {
anyhow::bail!("response body {len} bytes exceeds cap of {max} bytes");
}
}
let mut resp = resp;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = resp.chunk().await? {
if buf.len() as u64 + chunk.len() as u64 > max {
anyhow::bail!("response body exceeded cap of {max} bytes");
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
/// How the connector authenticates with Artifactory.
///
/// The derived `Debug` used to print the password and the access token in full,
/// so any `{:?}` of a connector's `auth` — a log line, an `anyhow` context, a
/// panic message — was a credential leak. `Debug` is now hand-written and
/// redacted (see below); the variant name is all it says.
#[derive(Clone)]
pub enum ConnectorAuth {
/// No authentication (anonymous).
None,
/// HTTP Basic Auth (username + password / API key).
Basic { username: String, password: String },
/// Bearer access token (Artifactory access token or OIDC bearer).
OidcToken { token: String },
/// mTLS client cert (built into the reqwest client).
Mtls,
}
impl std::fmt::Debug for ConnectorAuth {
/// Redacted: the variant, never the credential.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
ConnectorAuth::None => "None",
ConnectorAuth::Basic { .. } => "Basic",
ConnectorAuth::OidcToken { .. } => "OidcToken",
ConnectorAuth::Mtls => "Mtls",
};
write!(f, "ConnectorAuth::{name}(***)")
}
}
/// A holger-agent connector for a JFrog Artifactory instance.
pub struct ArtifactoryConnector {
/// Base URL up to (not including) `/artifactory`, e.g. `https://art.example.com`.
pub url: String,
pub auth: ConnectorAuth,
client: reqwest::Client,
}
impl ArtifactoryConnector {
/// Build a connector with optional Basic credentials.
pub fn new(url: String, username: Option<String>, password: Option<String>) -> Result<Self> {
let auth = match (username, password) {
(Some(u), Some(p)) => ConnectorAuth::Basic {
username: u,
password: p,
},
_ => ConnectorAuth::None,
};
let client = tls_roots(reqwest::Client::builder())
.user_agent("holger-agent/0.1.0")
// No-follow: the same-origin download guard validates only the initial
// url, so a compromised Artifactory could 3xx-redirect past it.
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self {
url: url.trim_end_matches('/').to_string(),
auth,
client,
})
}
/// Build a connector with a Bearer access token (Artifactory access token /
/// OIDC bearer).
pub fn with_oidc_token(url: String, token: String) -> Result<Self> {
let client = tls_roots(reqwest::Client::builder())
.user_agent("holger-agent/0.1.0")
// No-follow: the same-origin download guard validates only the initial
// url, so a compromised Artifactory could 3xx-redirect past it.
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self {
url: url.trim_end_matches('/').to_string(),
auth: ConnectorAuth::OidcToken { token },
client,
})
}
/// Build a connector with an mTLS client cert.
pub fn with_mtls(url: String, cert_pem: &[u8], key_pem: &[u8]) -> Result<Self> {
let identity = reqwest::Identity::from_pem(&[cert_pem, key_pem].concat())?;
let client = tls_roots(reqwest::Client::builder())
.user_agent("holger-agent/0.1.0")
// No-follow: the same-origin download guard validates only the initial
// url, so a compromised Artifactory could 3xx-redirect past it.
.redirect(reqwest::redirect::Policy::none())
.identity(identity)
.build()?;
Ok(Self {
url: url.trim_end_matches('/').to_string(),
auth: ConnectorAuth::Mtls,
client,
})
}
fn authed_request(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.auth {
ConnectorAuth::Basic { username, password } => req.basic_auth(username, Some(password)),
ConnectorAuth::OidcToken { token } => req.bearer_auth(token),
ConnectorAuth::None | ConnectorAuth::Mtls => req,
}
}
/// `true` iff `target` is same scheme+host(+port) as the configured base URL
/// — the SSRF / credential-exfiltration guard (same as the Nexus connector).
fn same_origin_as_base(&self, target: &str) -> bool {
let (base, target) = match (reqwest::Url::parse(&self.url), reqwest::Url::parse(target)) {
(Ok(b), Ok(t)) => (b, t),
_ => return false,
};
base.scheme() == target.scheme()
&& base.host_str() == target.host_str()
&& base.port_or_known_default() == target.port_or_known_default()
}
/// Construct the download URL for an artifact at `repo`/`path`/`name`.
fn download_url(&self, repo: &str, path: &str, name: &str) -> String {
let mut p = String::new();
if !path.is_empty() && path != "." {
p.push_str(path.trim_matches('/'));
p.push('/');
}
p.push_str(name);
format!("{}/artifactory/{}/{}", self.url, repo, p)
}
/// Pure builder for the AQL body listing a repo's FILES (P22 — `type:"file"`
/// drops folder rows that otherwise leak through as bogus assets), covering
/// both a LOCAL repo and a REMOTE repo's `<repo>-cache` (P21). The repo name
/// is interpolated via `serde_json` so a `"`/`\`/`{` in the name is escaped,
/// not injected (P23); an outright control char is rejected.
fn build_aql(repository: &str) -> Result<String> {
if repository.contains(['"', '\\', '{', '}', '\n', '\r']) {
anyhow::bail!("invalid Artifactory repo name (unsafe AQL chars): {repository:?}");
}
let criteria = serde_json::json!({
"type": "file",
"$or": [
{ "repo": repository },
{ "repo": format!("{repository}-cache") },
],
});
let criteria = serde_json::to_string(&criteria)?;
// `actual_sha256` and not `sha256`: AQL exposes both, and they are
// different claims. `sha256` is the digest the CLIENT declared when it
// uploaded; `actual_sha256` is what Artifactory computed over the bytes
// it stored. The field exists so the bytes that cross the airgap are
// the bytes the source holds, so the server's own reading is the only
// one worth carrying.
Ok(format!(
r#"items.find({criteria}).include("repo","path","name","size","type","actual_sha256")"#
))
}
/// Pure mapping of AQL rows → assets: drop folder rows (defence-in-depth on
/// top of the `type:"file"` query) and rows with no concrete file name; join
/// `path`/`name` into the relative path and build each download URL.
fn map_results(&self, results: Vec<AqlResult>) -> Vec<RemoteAsset> {
results
.into_iter()
.filter(|r| r.item_type.as_deref().map(|t| t == "file").unwrap_or(true))
.filter(|r| r.name != "." && !r.name.is_empty())
.map(|r| {
let rel = if r.path.is_empty() || r.path == "." {
r.name.clone()
} else {
format!("{}/{}", r.path.trim_matches('/'), r.name)
};
RemoteAsset {
download_url: self.download_url(&r.repo, &r.path, &r.name),
path: rel,
content_type: None,
size: r.size,
sha256: normalise_sha256(r.actual_sha256),
// Artifactory publishes no change token; a real digest is what it
// answers with, and this stays absent rather than invented.
change_token: None,
}
})
.collect()
}
}
#[derive(serde::Deserialize)]
struct ArtifactoryRepoResponse {
key: String,
#[serde(rename = "type")]
repo_type: String,
#[serde(rename = "packageType")]
package_type: Option<String>,
url: Option<String>,
}
#[derive(serde::Deserialize)]
struct AqlResult {
repo: String,
path: String,
name: String,
size: Option<u64>,
/// AQL `type` field ("file"/"folder") when `.include("type")` is set; used to
/// drop folder rows. `None` (older servers / not included) → treated as file.
#[serde(rename = "type", default)]
item_type: Option<String>,
/// The digest Artifactory computed over the stored bytes (`actual_sha256`),
/// not the one an uploading client declared. `default` because an older
/// server, or one that never hashed a blob, omits the field — that is a
/// source with no digest to publish, not an error.
#[serde(default)]
actual_sha256: Option<String>,
}
/// Normalise a published digest to the shape [`RemoteAsset::sha256`] documents:
/// lowercase hex, 64 characters, no algorithm prefix.
///
/// Anything else becomes `None`. A malformed digest must not be carried
/// forward: the field exists so the bytes that cross the airgap are the bytes
/// the source pinned, and a value that cannot be compared is worse than an
/// absent one — it would fail verification later, far from the server that sent
/// it, looking like corruption rather than like a source with no sha256.
fn normalise_sha256(raw: Option<String>) -> Option<String> {
let s = raw?;
let s = s.trim().to_ascii_lowercase();
(s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())).then_some(s)
}
#[derive(serde::Deserialize)]
struct AqlResponse {
results: Vec<AqlResult>,
}
#[async_trait]
impl ConnectorTrait for ArtifactoryConnector {
fn name(&self) -> &str {
"artifactory"
}
async fn list_repositories(&self) -> Result<Vec<RemoteRepository>> {
let url = format!("{}/artifactory/api/repositories", self.url);
let req = self.authed_request(self.client.get(&url));
let resp = req.send().await?.error_for_status()?;
let body = read_capped(resp, MAX_JSON_BYTES).await?;
let repos: Vec<ArtifactoryRepoResponse> = serde_json::from_slice(&body)?;
Ok(repos
.into_iter()
.map(|r| RemoteRepository {
name: r.key,
// packageType is the ecosystem (maven, npm, pypi, …); type is
// LOCAL/REMOTE/VIRTUAL. We surface packageType as the format
// (what holger maps via ArtifactFormat::from_format_str).
format: r.package_type.unwrap_or_else(|| "generic".to_string()),
repo_type: r.repo_type,
url: r.url,
})
.collect())
}
async fn list_assets(&self, repository: &str) -> Result<Vec<RemoteAsset>> {
// AQL find every FILE in the repo (and its remote `<repo>-cache`),
// including the fields we need to build download URLs + sizes.
let aql = Self::build_aql(repository)?;
let url = format!("{}/artifactory/api/search/aql", self.url);
let req = self
.authed_request(self.client.post(&url))
.header(reqwest::header::CONTENT_TYPE, "text/plain")
.body(aql);
let resp = req.send().await?.error_for_status()?;
let body = read_capped(resp, MAX_JSON_BYTES).await?;
let page: AqlResponse = serde_json::from_slice(&body)?;
let assets = self.map_results(page.results);
// A REMOTE repo's artifacts live under `<repo>-cache` (covered by the
// query); a VIRTUAL repo has no items of its own. Either way a 0-asset
// result is silent data loss in a "successful" migration, so warn.
if assets.is_empty() {
log::warn!(
"Artifactory repo {repository:?} returned 0 assets — a VIRTUAL repo (resolve its \
members), an empty repo, or a permissions gap. Nothing will be migrated."
);
}
Ok(assets)
}
async fn download_asset(&self, asset: &RemoteAsset) -> Result<Vec<u8>> {
// SSRF + credential-exfiltration guard: only attach credentials to a
// same-origin URL (we build these ourselves, but defend in depth).
if !self.same_origin_as_base(&asset.download_url) {
anyhow::bail!(
"refusing cross-origin download: {} is not same-origin as base {}",
asset.download_url,
self.url
);
}
let req = self.authed_request(self.client.get(&asset.download_url));
let resp = req.send().await?.error_for_status()?;
read_capped(resp, MAX_DOWNLOAD_BYTES).await
}
async fn upload_asset(&self, repository: &str, path: &str, data: &[u8]) -> Result<()> {
// Artifactory deploy: PUT /artifactory/<repo>/<path>.
let url = format!(
"{}/artifactory/{}/{}",
self.url,
repository,
path.trim_start_matches('/')
);
let req = self.authed_request(self.client.put(&url).body(data.to_vec()));
req.send().await?.error_for_status()?;
Ok(())
}
}
/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
let _ = rustls::crypto::ring::default_provider().install_default();
b.tls_certs_only(
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|c| reqwest::Certificate::from_der(c).ok()),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn conn() -> ArtifactoryConnector {
ArtifactoryConnector::new(
"https://art.example.com:443/".to_string(),
Some("u".to_string()),
Some("p".to_string()),
)
.unwrap()
}
#[test]
fn base_url_is_trimmed() {
assert_eq!(conn().url, "https://art.example.com:443");
}
#[test]
fn download_url_joins_repo_path_name() {
let c = conn();
assert_eq!(
c.download_url("libs-release", "com/foo/bar/1.0", "bar-1.0.jar"),
"https://art.example.com:443/artifactory/libs-release/com/foo/bar/1.0/bar-1.0.jar"
);
// No path (root-level artifact).
assert_eq!(
c.download_url("crates-local", "", "serde-1.0.0.crate"),
"https://art.example.com:443/artifactory/crates-local/serde-1.0.0.crate"
);
}
#[test]
fn same_origin_matches_base_host_scheme_port() {
let c = conn();
assert!(c.same_origin_as_base("https://art.example.com:443/artifactory/r/a.jar"));
assert!(!c.same_origin_as_base("http://169.254.169.254/latest/meta-data/"));
assert!(!c.same_origin_as_base("https://evil.example.com/a.jar"));
assert!(!c.same_origin_as_base("http://art.example.com:443/a.jar")); // scheme
}
#[tokio::test]
async fn download_rejects_cross_origin_before_sending_creds() {
let c = ArtifactoryConnector::new(
"https://art.example.com/".to_string(),
Some("u".to_string()),
Some("secret".to_string()),
)
.unwrap();
let asset = RemoteAsset {
path: "p".into(),
download_url: "http://169.254.169.254/latest/meta-data/iam/".into(),
content_type: None,
size: None,
sha256: None,
change_token: None,
};
let err = c.download_asset(&asset).await.expect_err("must reject");
assert!(
err.to_string().contains("cross-origin"),
"unexpected: {err}"
);
}
#[test]
fn bearer_and_basic_auth_select_correctly() {
let bearer =
ArtifactoryConnector::with_oidc_token("https://art.example.com".into(), "tok".into())
.unwrap();
assert!(matches!(bearer.auth, ConnectorAuth::OidcToken { .. }));
assert!(matches!(conn().auth, ConnectorAuth::Basic { .. }));
// No creds => anonymous.
let anon = ArtifactoryConnector::new("https://art.example.com".into(), None, None).unwrap();
assert!(matches!(anon.auth, ConnectorAuth::None));
}
/// T28: build_aql constrains to files, covers the remote `<repo>-cache`, and
/// escapes/rejects unsafe repo names; map_results drops folders and joins paths.
#[test]
fn build_aql_filters_files_and_escapes_repo() {
let aql = ArtifactoryConnector::build_aql("libs-release").unwrap();
assert!(
aql.contains(r#""type":"file""#),
"AQL constrains to files: {aql}"
);
assert!(
aql.contains(r#""repo":"libs-release""#),
"queries the repo: {aql}"
);
assert!(
aql.contains(r#""repo":"libs-release-cache""#),
"covers the remote cache: {aql}"
);
assert!(
aql.contains(r#".include("repo","path","name","size","type","actual_sha256")"#),
"includes type and the server-computed digest: {aql}"
);
// `actual_sha256`, never the client-declared `sha256`: asked for as a
// whole word so a future edit cannot satisfy this by including the
// weaker claim instead.
assert!(
!aql.contains(r#","sha256""#),
"the client-declared digest must not be asked for: {aql}"
);
// A `"` in the name is JSON-escaped (not injected) OR rejected: our policy
// rejects AQL-breaking chars outright.
assert!(
ArtifactoryConnector::build_aql(r#"a"b"#).is_err(),
"reject quote in repo name"
);
assert!(
ArtifactoryConnector::build_aql("a{b}").is_err(),
"reject braces in repo name"
);
assert!(
ArtifactoryConnector::build_aql("a\nb").is_err(),
"reject newline in repo name"
);
}
#[test]
fn map_results_drops_folders_and_joins_paths() {
let c = conn();
let rows = vec![
AqlResult {
repo: "r".into(),
path: "com/foo".into(),
name: "bar-1.0.jar".into(),
size: Some(10),
item_type: Some("file".into()),
actual_sha256: Some("A".repeat(64)),
},
AqlResult {
repo: "r".into(),
path: "com".into(),
name: "foo".into(),
size: None,
item_type: Some("folder".into()),
actual_sha256: None,
},
AqlResult {
repo: "r".into(),
path: ".".into(),
name: "root.crate".into(),
size: Some(5),
item_type: None,
actual_sha256: Some("not-a-digest".into()),
},
];
let assets = c.map_results(rows);
assert_eq!(assets.len(), 2, "the folder row is dropped");
// The server-computed digest reaches the asset, lowercased.
assert_eq!(assets[0].sha256.as_deref(), Some("a".repeat(64).as_str()));
// …and a value that is not a sha256 becomes None rather than being
// carried forward to fail verification somewhere else.
assert_eq!(
assets[1].sha256, None,
"a malformed digest must not be carried"
);
assert_eq!(assets[0].path, "com/foo/bar-1.0.jar", "nested path joins");
assert_eq!(
assets[0].download_url,
"https://art.example.com:443/artifactory/r/com/foo/bar-1.0.jar"
);
assert_eq!(assets[1].path, "root.crate", "root path == name");
}
/// T32: the same-origin guard rejects a different port (the case the Nexus
/// twin test covers).
#[test]
fn same_origin_rejects_different_port() {
let c = conn(); // base is :443
assert!(
!c.same_origin_as_base("https://art.example.com:9999/a.jar"),
"different port rejected"
);
}
}