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

nordisk/holger

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