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