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

nordisk/Skidbladnir

src/federation/azure.rs

//! **Azure Artifacts** harbour (Azure DevOps feeds).
//!
//! Azure Artifacts serves each ecosystem from its own protocol path under a feed:
//! `https://pkgs.dev.azure.com/{org}[/{project}]/_packaging/{feed}/{proto}/…`.
//! We speak those documented HTTP endpoints directly (see the Azure DevOps REST
//! reference for Artifacts). Auth is an Azure DevOps **PAT** carried as HTTP Basic
//! (any username, PAT as the password — the documented PAT-over-Basic scheme) or
//! an AAD **Bearer** token, both via the shared [`UpstreamAuth`].
//!
//! We deliberately do NOT vendor the async `azure_*` SDK: it has no stable
//! Artifacts-feed download client, and pulling it (plus its TLS stack) would drag
//! a C/cmake toolchain into the tree, violating the nordisk pure-Rust charter.
//! The PAT/bearer token is provisioned out-of-band exactly as `az artifacts` /
//! the package managers consume it — no crypto is hand-rolled here.

use anyhow::Result;

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

/// A read-only harbour proxying one Azure Artifacts feed.
pub struct AzureArtifactsRegistry {
    name: String,
    /// Azure DevOps organization, e.g. `contoso` (→ `https://pkgs.dev.azure.com/contoso`).
    organization: String,
    /// Optional project. `None` = an organization-scoped feed.
    project: Option<String>,
    /// The feed name.
    feed: String,
    auth: UpstreamAuth,
    format: RegistryFormat,
    /// Cached origin base used for the same-origin credential guard.
    base_url: String,
}

impl AzureArtifactsRegistry {
    pub fn new(
        name: impl Into<String>,
        organization: impl Into<String>,
        project: Option<String>,
        feed: impl Into<String>,
        auth: UpstreamAuth,
        format: RegistryFormat,
    ) -> Self {
        let organization = organization.into();
        Self {
            name: name.into(),
            base_url: format!("https://pkgs.dev.azure.com/{organization}"),
            organization,
            project,
            feed: feed.into(),
            auth,
            format,
        }
    }

    /// Point at an arbitrary host (tests / Azure DevOps Server on-prem). `host` is
    /// the scheme+host base, e.g. `http://127.0.0.1:1234`.
    pub fn with_host(
        name: impl Into<String>,
        host: impl Into<String>,
        organization: impl Into<String>,
        project: Option<String>,
        feed: impl Into<String>,
        auth: UpstreamAuth,
        format: RegistryFormat,
    ) -> Self {
        let organization = organization.into();
        let host = host.into();
        Self {
            name: name.into(),
            base_url: format!("{}/{organization}", host.trim_end_matches('/')),
            organization,
            project,
            feed: feed.into(),
            auth,
            format,
        }
    }

    /// The `{org}[/{project}]/_packaging/{feed}` prefix (project-scoped when set).
    fn feed_prefix(&self) -> String {
        match &self.project {
            Some(p) => format!("{}/{p}/_packaging/{}", self.organization, self.feed),
            None => format!("{}/_packaging/{}", self.organization, self.feed),
        }
    }

    /// Full content URL for one artifact, per the feed's protocol layout.
    pub fn content_url(&self, id: &ArtifactCoord) -> String {
        // `base_url` already ends in `/{org}`; strip the trailing org so the
        // feed_prefix (which starts with the org) is not doubled.
        let host = self
            .base_url
            .strip_suffix(&self.organization)
            .map(|s| s.trim_end_matches('/'))
            .unwrap_or(&self.base_url);
        let prefix = self.feed_prefix();
        let name = &id.name;
        let ver = &id.version;
        let tail = match self.format {
            RegistryFormat::Maven => {
                let g = id.namespace.as_deref().unwrap_or("").replace('.', "/");
                format!("maven/v1/{g}/{name}/{ver}/{name}-{ver}.jar")
            }
            RegistryFormat::Npm => format!("npm/registry/{name}/-/{name}-{ver}.tgz"),
            RegistryFormat::Nuget => {
                let n = name.to_lowercase();
                format!("nuget/v3/flat2/{n}/{ver}/{n}.{ver}.nupkg")
            }
            RegistryFormat::Pip => format!("pypi/download/{name}/{ver}/{name}-{ver}.tar.gz"),
            RegistryFormat::Rust => format!("cargo/api/v1/crates/{name}/{ver}/download"),
            RegistryFormat::Gem => format!("rubygems/gems/{name}-{ver}.gem"),
            // Azure Artifacts has no OCI feed (OCI images are served by the
            // OciDistributionRegistry harbour) → fall back to the universal-package path.
            RegistryFormat::Oci => format!("upack/packages/{name}/{ver}"),
            RegistryFormat::Raw => format!("upack/packages/{name}/{ver}"),
        };
        format!("{host}/{prefix}/{tail}")
    }
}

#[cfg(feature = "federation")]
impl UpstreamRegistry for AzureArtifactsRegistry {
    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 {
    use super::super::testutil::MockServer;
    use super::*;

    fn id(name: &str, version: &str) -> ArtifactCoord {
        ArtifactCoord::new(name, version)
    }

    #[test]
    fn content_url_npm_project_scoped() {
        let up = AzureArtifactsRegistry::new(
            "az",
            "contoso",
            Some("proj".into()),
            "myfeed",
            UpstreamAuth::None,
            RegistryFormat::Npm,
        );
        assert_eq!(
            up.content_url(&id("left-pad", "1.3.0")),
            "https://pkgs.dev.azure.com/contoso/proj/_packaging/myfeed/npm/registry/left-pad/-/left-pad-1.3.0.tgz"
        );
    }

    #[test]
    fn content_url_maven_org_scoped() {
        let up = AzureArtifactsRegistry::new(
            "az",
            "contoso",
            None,
            "myfeed",
            UpstreamAuth::None,
            RegistryFormat::Maven,
        );
        let mvn = ArtifactCoord::with_namespace("com.example", "lib", "2.1");
        assert_eq!(
            up.content_url(&mvn),
            "https://pkgs.dev.azure.com/contoso/_packaging/myfeed/maven/v1/com/example/lib/2.1/lib-2.1.jar"
        );
    }

    #[test]
    fn pat_over_basic_auth_and_fetch_against_mock() {
        let server = MockServer::start(|method, path| {
            if method == "GET" && path.contains("/cargo/api/v1/crates/serde/1.0.0/download") {
                (200, b"serde-crate".to_vec())
            } else {
                (404, b"no".to_vec())
            }
        });
        let up = AzureArtifactsRegistry::with_host(
            "az",
            &server.base_url,
            "contoso",
            Some("proj".into()),
            "myfeed",
            UpstreamAuth::Basic {
                username: "pat".into(),
                password: "TOKEN123".into(),
            },
            RegistryFormat::Rust,
        );
        let got = up.fetch(&id("serde", "1.0.0")).unwrap();
        assert_eq!(got.as_deref(), Some(b"serde-crate".as_ref()));
        // base64("pat:TOKEN123")
        let expect = format!(
            "Basic {}",
            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, "pat:TOKEN123")
        );
        assert_eq!(
            server.requests()[0].header("authorization"),
            Some(expect.as_str())
        );
        crate::functional_status(
            "skidbladnir/federation-azure",
            "pat_over_basic_fetch",
            got.as_deref() == Some(b"serde-crate".as_ref()),
            "Azure feed fetch with PAT-over-Basic auth",
        );
    }

    #[test]
    fn missing_is_clean_none() {
        let server = MockServer::start(|_m, _p| (404, b"no".to_vec()));
        let up = AzureArtifactsRegistry::with_host(
            "az",
            &server.base_url,
            "contoso",
            None,
            "myfeed",
            UpstreamAuth::None,
            RegistryFormat::Npm,
        );
        assert!(up.fetch(&id("ghost", "0.0.0")).unwrap().is_none());
    }
}