diff --git a/docs/front-matter.md b/docs/front-matter.md index 8c053ce24..f5cda71ee 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -271,6 +271,10 @@ supply-chain: # optional internal supply-chain mirror (see docs name: myacr.azurecr.io/mirror # registry host or base path (artifact names kept under it) service-connection: acr-conn # REQUIRED when registry is set (ACR has no System.AccessToken path) service-connection: shared-conn # optional feed/registry fallback; never applies to pipeline-artifact + packages: # optional shared package feed for runtimes.python/node/dotnet + feed: my-proj/my-feed # feed name or project/feed; scalar `packages: my-feed` shorthand also works + # organization: myorg # optional; defaults to the org inferred from the git remote + # python: true # per-ecosystem opt-out (node:/dotnet: likewise); each defaults to true # ado-aw-debug: # debug-only knobs; see docs/ado-aw-debug.md # skip-integrity: false # omit generated pipeline integrity verification parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run) diff --git a/docs/runtimes.md b/docs/runtimes.md index f0922108c..9b659730e 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -58,6 +58,12 @@ runtimes: | `feed-url` | string | Internal PyPI feed URL. Injects `PIP_INDEX_URL` and `UV_DEFAULT_INDEX` env vars into the agent environment. | | `config` | string | Path to a pip/uv config file. Accepted with a warning — the file will not be available inside the AWF agent environment until proxy-auth support lands. | +> **Shared feed:** `feed-url` is optional. When +> [`supply-chain.packages`](supply-chain.md#shared-package-feed-for-the-runtimes-packages) +> declares one feed identity, this runtime derives its endpoint from it +> automatically. An explicit `feed-url` here overrides the shared feed; a +> `config` file suppresses it. + When enabled, the compiler: - Contributes a `UsePythonVersion@0` task to `Declarations::agent_prepare_steps` (runs before AWF) - If `feed-url` is set, also injects `PipAuthenticate@1` to authenticate the ADO build service identity for internal feeds @@ -93,6 +99,12 @@ runtimes: | `feed-url` | string | Internal npm registry URL. Injects `NPM_CONFIG_REGISTRY` env var into the agent environment. | | `config` | string | Path to an .npmrc config file. Accepted with a warning — the file will not be available inside the AWF agent environment until proxy-auth support lands. | +> **Shared feed:** `feed-url` is optional. When +> [`supply-chain.packages`](supply-chain.md#shared-package-feed-for-the-runtimes-packages) +> declares one feed identity, this runtime derives its endpoint from it +> automatically. An explicit `feed-url` here overrides the shared feed; a +> `config` file suppresses it. + When enabled, the compiler: - Contributes a `UseNode@1` task to `Declarations::agent_prepare_steps` (runs before AWF) - If `feed-url` or `config` is set, also injects `npmAuthenticate@0` (and an ensure-`.npmrc` step) to authenticate the ADO build service identity for internal feeds @@ -137,6 +149,12 @@ runtimes: | `feed-url` | string | Internal NuGet feed URL (typically the v3 `index.json` of an Azure Artifacts feed). When set, the compiler creates a minimal `nuget.config` if none exists and runs `NuGetAuthenticate@1`. | | `config` | string | Path to a checked-in `nuget.config` in the repo. When set, the compiler runs `NuGetAuthenticate@1` (which auto-discovers `nuget.config` files in the workspace). Mutually exclusive with `feed-url`. | +> **Shared feed:** `feed-url` is optional. When +> [`supply-chain.packages`](supply-chain.md#shared-package-feed-for-the-runtimes-packages) +> declares one feed identity, this runtime derives its endpoint from it +> automatically. An explicit `feed-url` here overrides the shared feed; a +> `config` file suppresses it. + **`global.json` precedence.** A `global.json` file in the repo is the canonical way to pin the .NET SDK. The compiler enforces a single source of truth: diff --git a/docs/supply-chain.md b/docs/supply-chain.md index 008860b3c..1a17bbfb3 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -36,6 +36,8 @@ supply-chain: name: myacr.azurecr.io/mirror # registry host or base path service-connection: acr-conn # required when registry is set service-connection: shared-conn # optional shared fallback for both targets + packages: # shared package feed for the language runtimes + feed: my-proj/my-feed # feed name or "project/feed" ``` | Field | Type | Required | Purpose | @@ -43,6 +45,7 @@ supply-chain: | `feed` | scalar **or** `{ name, service-connection }` | optional | Enables the binary mirror (#1–#3). A bare string is shorthand for `{ name: }`. | | `pipeline-artifact` | `{ project, definition-id, run-id, artifact }` | optional | Uses one exact producer run as the complete binary source (#1–#3). Mutually exclusive with `feed`. | | `registry` | scalar **or** `{ name, service-connection }` | optional | Enables the image mirror (#4), independently of either binary source. | +| `packages` | scalar **or** `{ feed, organization, project, python, node, dotnet }` | optional | One shared package-feed identity that the `python`, `node`, and `dotnet` runtimes each restore packages from. Independent of every other key. | | `service-connection` | string | optional | Shared fallback connection for `feed` and `registry`; it does not apply to `pipeline-artifact`. | `feed` and `pipeline-artifact` are mutually exclusive. `registry` is @@ -225,6 +228,97 @@ passes both `--image-tag ` and `--image-registry ` directly to the AWF invocation so `--skip-pull` resolves every pre-pulled image (including `api-proxy`) under the mirror name instead of GHCR. +## Shared package feed for the runtimes (`packages`) + +`feed` / `pipeline-artifact` / `registry` all mirror **ado-aw's own** +artifacts. `packages` is a different concern: it is the feed that the language +runtimes restore **your project's** packages from. Without it, every runtime +needs its own `feed-url:`: + +```yaml +runtimes: + python: + feed-url: "https://pkgs.dev.azure.com/myorg/my-proj/_packaging/my-feed/pypi/simple/" + node: + feed-url: "https://pkgs.dev.azure.com/myorg/my-proj/_packaging/my-feed/npm/registry/" + dotnet: + feed-url: "https://pkgs.dev.azure.com/myorg/my-proj/_packaging/my-feed/nuget/v3/index.json" +``` + +An Azure Artifacts feed is multi-protocol, so those three URLs are one feed. +`supply-chain.packages` declares that feed **identity** once and lets the +compiler derive each endpoint: + +```yaml +runtimes: + python: true + node: true + dotnet: true +supply-chain: + packages: my-proj/my-feed +``` + +`packages` is independent of `feed`, `pipeline-artifact`, and `registry` — set +it alone or alongside any of them. + +### Fields + +| Field | Type | Required | Purpose | +|-------|------|----------|---------| +| `feed` | string | **yes** | Feed name (org-scoped feed) or `project/feed` (project-scoped feed). | +| `organization` | string | optional | ADO organization. Defaults to the org inferred from the repository's git remote. | +| `project` | string | optional | ADO project. Mutually exclusive with the `project/feed` form of `feed`. | +| `python` / `node` / `dotnet` | bool | optional | Per-ecosystem opt-out; each defaults to `true`. | + +A bare scalar is sugar for `{ feed: }`: + +```yaml +supply-chain: + packages: my-feed # same as { feed: my-feed } +``` + +### Derived endpoints + +For organization `ORG`, optional project `PROJ`, and feed `FEED`: + +| Runtime | Derived URL | Applied as | +|---------|-------------|------------| +| `python` | `https://pkgs.dev.azure.com/ORG/PROJ/_packaging/FEED/pypi/simple/` | `PIP_INDEX_URL` + `UV_DEFAULT_INDEX` env vars, plus `PipAuthenticate@1` | +| `node` | `https://pkgs.dev.azure.com/ORG/PROJ/_packaging/FEED/npm/registry/` | `NPM_CONFIG_REGISTRY` env var, plus an ensure-`.npmrc` step and `npmAuthenticate@0` | +| `dotnet` | `https://pkgs.dev.azure.com/ORG/PROJ/_packaging/FEED/nuget/v3/index.json` | generated `nuget.config` package source, plus `NuGetAuthenticate@1` | + +The `/PROJ` segment is omitted for an org-scoped feed (a bare `feed:` with no +`project:`). The organization and project are embedded verbatim, so both must +match `[A-Za-z0-9._-]`; anything requiring URL escaping is rejected at compile +time rather than silently producing a malformed URL. Compilation also fails +with an actionable message when no organization can be resolved — set +`organization:` explicitly when compiling outside an Azure DevOps clone. + +### Precedence + +For each runtime, the effective package source resolves as: + +1. `runtimes..feed-url` — an explicit per-runtime URL always wins. +2. `runtimes..config` — the checked-in config file owns the package + source, so `supply-chain.packages` is **not** applied. A compile warning is + emitted when both are set. +3. `supply-chain.packages` (when the ecosystem has not opted out). +4. The ecosystem's public default (PyPI / npmjs / nuget.org). + +### Authentication + +No service connection is involved. The runtimes authenticate with the standard +`PipAuthenticate@1` / `npmAuthenticate@0` / `NuGetAuthenticate@1` tasks under +the pipeline's build identity, exactly as they do for a per-runtime +`feed-url:`. Grant that identity the **Feed Reader** role on the feed. This +matches the same-organization feed story described under +[Authentication](#authentication) above; cross-organization package feeds are +not supported by this key — use a per-runtime `feed-url:` plus your own +authentication step. + +`pkgs.dev.azure.com` is already in the agent's default AWF allowlist, so no +`network:` change is needed for the agent to restore packages from the feed. + ## Examples Mirror everything, two different connections: @@ -280,6 +374,20 @@ supply-chain: service-connection: acr-conn ``` +One shared package feed for all three runtimes, with npm left on the public +registry: + +```yaml +runtimes: + python: true + node: true + dotnet: true +supply-chain: + packages: + feed: my-proj/my-feed + node: false +``` + ## Network isolation note The mirror fetches (`NuGetAuthenticate@1`, `DownloadPackage@1`, diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 534835b2b..783887cb1 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -20,7 +20,7 @@ use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; -use super::types::FrontMatter; +use super::types::{FrontMatter, PackageEcosystem}; // ────────────────────────────────────────────────────────────────────── // MCPG types (used by both the trait and standalone compiler) @@ -175,6 +175,44 @@ impl<'a> CompileContext<'a> { }) } + /// Resolve the shared `supply-chain.packages` feed endpoint for + /// `ecosystem`, if one is configured and the ecosystem opts in. + /// + /// Returns `Ok(None)` when no `supply-chain.packages` block is present or + /// the ecosystem is switched off; returns `Err` when a feed is configured + /// but no organization can be determined (see + /// [`PackageFeedConfig::url_for`](crate::compile::types::PackageFeedConfig::url_for)). + pub fn package_feed_url(&self, ecosystem: PackageEcosystem) -> Result> { + let Some(packages) = self + .front_matter + .supply_chain() + .and_then(|sc| sc.packages.as_ref()) + else { + return Ok(None); + }; + if !packages.applies_to(ecosystem) { + return Ok(None); + } + let url = packages.url_for(ecosystem, self.ado_org())?; + crate::validate::validate_feed_url(&url, "supply-chain.packages")?; + Ok(Some(url)) + } + + /// Cheap presence check: is a `supply-chain.packages` block configured + /// that opts `ecosystem` in? + /// + /// Unlike [`Self::package_feed_url`] this performs no organization + /// resolution and no URL validation, so it never fails. Use it in + /// diagnostic-only paths (e.g. deciding whether to warn that a + /// runtime-local `config` takes precedence) where the resolved URL is + /// never consumed. + pub fn has_package_feed(&self, ecosystem: PackageEcosystem) -> bool { + self.front_matter + .supply_chain() + .and_then(|sc| sc.packages.as_ref()) + .is_some_and(|packages| packages.applies_to(ecosystem)) + } + fn ado_context_override() -> Result> { let Some(value) = std::env::var_os(COMPILE_REMOTE_URL_ENV) else { return Ok(None); diff --git a/src/compile/types.rs b/src/compile/types.rs index 41f9199ac..7bf9985f9 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2776,6 +2776,12 @@ pub struct SupplyChainConfig { /// images. When omitted images are pulled from GHCR as today. #[serde(default)] pub registry: Option, + /// One internal Azure DevOps Artifacts feed identity that every runtime + /// (`python`, `node`, `dotnet`) picks up as its package source, instead + /// of repeating a per-ecosystem `feed-url:` on each runtime. Independent + /// of `feed` / `pipeline-artifact` / `registry`. + #[serde(default)] + pub packages: Option, /// Shared fallback service connection for feed and registry targets. It /// never applies to `pipeline-artifact`. #[serde(default, rename = "service-connection")] @@ -2806,6 +2812,203 @@ pub struct RegistryConfig { pub service_connection: Option, } +/// The package ecosystems that can consume the shared +/// [`PackageFeedConfig`] feed identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageEcosystem { + /// pip / uv (PyPI-compatible simple index). + Python, + /// npm (registry endpoint). + Node, + /// NuGet (v3 service index). + Dotnet, +} + +impl PackageEcosystem { + /// The protocol-specific URL suffix appended after the feed segment. + fn url_suffix(self) -> &'static str { + match self { + PackageEcosystem::Python => "pypi/simple/", + PackageEcosystem::Node => "npm/registry/", + PackageEcosystem::Dotnet => "nuget/v3/index.json", + } + } +} + +/// A shared internal package-feed identity for the language runtimes. +/// +/// Lives under `supply-chain.packages`. Unlike [`FeedConfig`] (which mirrors +/// the `ado-aw` / `awf` / `ado-script` binaries) this describes the feed that +/// `pip`, `npm`, and `dotnet` should restore packages from. It carries a feed +/// *identity* rather than a URL, and the compiler derives the three +/// protocol-specific endpoints from it — see +/// [`PackageFeedConfig::url_for`]. +/// +/// Accepts either a bare scalar (the feed reference) or an object. +#[derive(Debug, Clone)] +pub struct PackageFeedConfig { + /// Feed reference: a bare feed name (org-scoped feed) or `project/feed` + /// (project-scoped feed). + pub feed: crate::secure::FeedRef, + /// Organization override. When omitted the org is inferred from the git + /// remote of the repository being compiled. + pub organization: Option, + /// Project override. Mutually exclusive with the `project/feed` form of + /// `feed`. + pub project: Option, + /// Whether the Python runtime picks up this feed (default `true`). + pub python: bool, + /// Whether the Node runtime picks up this feed (default `true`). + pub node: bool, + /// Whether the .NET runtime picks up this feed (default `true`). + pub dotnet: bool, +} + +impl<'de> Deserialize<'de> for PackageFeedConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + fn default_true() -> bool { + true + } + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Obj { + feed: crate::secure::FeedRef, + #[serde(default)] + organization: Option, + #[serde(default)] + project: Option, + #[serde(default = "default_true")] + python: bool, + #[serde(default = "default_true")] + node: bool, + #[serde(default = "default_true")] + dotnet: bool, + } + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Scalar(crate::secure::FeedRef), + Obj(Obj), + } + Ok(match Repr::deserialize(deserializer)? { + Repr::Scalar(feed) => PackageFeedConfig { + feed, + organization: None, + project: None, + python: true, + node: true, + dotnet: true, + }, + Repr::Obj(o) => PackageFeedConfig { + feed: o.feed, + organization: o.organization, + project: o.project, + python: o.python, + node: o.node, + dotnet: o.dotnet, + }, + }) + } +} + +impl PackageFeedConfig { + /// Whether `ecosystem` opts into this shared feed. + pub fn applies_to(&self, ecosystem: PackageEcosystem) -> bool { + match ecosystem { + PackageEcosystem::Python => self.python, + PackageEcosystem::Node => self.node, + PackageEcosystem::Dotnet => self.dotnet, + } + } + + /// Split `feed` into its optional project prefix and the feed name. + fn split_feed(&self) -> (Option<&str>, &str) { + match self.feed.as_str().split_once('/') { + Some((project, feed)) => (Some(project), feed), + None => (None, self.feed.as_str()), + } + } + + /// The effective project segment, or `None` for an org-scoped feed. + /// + /// Resolution: the `project/feed` prefix, else the `project:` override. + /// The two are mutually exclusive (rejected by [`Self::validate`]). + pub fn project_segment(&self) -> Option<&str> { + let (prefix, _) = self.split_feed(); + prefix.or(self.project.as_deref()) + } + + /// Derive the endpoint for `ecosystem` under organization `org`. + /// + /// `org` is used only when no `organization:` override is set. Both the + /// derived org and any project segment are embedded verbatim, so both are + /// checked against the URL-segment allowlist first — the derived org + /// comes from the git remote and is therefore not covered by front-matter + /// deserialization. + pub fn url_for(&self, ecosystem: PackageEcosystem, org: Option<&str>) -> anyhow::Result { + let org = match self.organization.as_deref() { + Some(explicit) => explicit, + None => org.ok_or_else(|| { + anyhow::anyhow!( + "supply-chain.packages: cannot determine the Azure DevOps \ + organization for feed '{}'. The compile directory has no \ + Azure DevOps git remote — set \ + `supply-chain.packages.organization` explicitly.", + self.feed.as_str() + ) + })?, + }; + if !crate::validate::is_valid_ado_url_segment(org) { + anyhow::bail!( + "supply-chain.packages: organization '{org}' must contain only \ + [A-Za-z0-9._-] so it can be embedded in a feed URL without \ + escaping. Set `supply-chain.packages.organization` explicitly." + ); + } + let (_, feed) = self.split_feed(); + let scope = match self.project_segment() { + Some(project) => format!("{org}/{project}"), + None => org.to_string(), + }; + Ok(format!( + "https://pkgs.dev.azure.com/{scope}/_packaging/{feed}/{}", + ecosystem.url_suffix() + )) + } + + /// Validate cross-field rules and URL-segment safety. + fn validate(&self) -> anyhow::Result<()> { + let (prefix, feed) = self.split_feed(); + if prefix.is_some() && self.project.is_some() { + anyhow::bail!( + "supply-chain.packages: `project` and the 'project/feed' form of \ + `feed` are mutually exclusive. Use one or the other." + ); + } + for (label, value) in [ + ( + "supply-chain.packages.feed", + Some(feed).filter(|v| !v.is_empty()), + ), + ("supply-chain.packages.feed (project)", prefix), + ] { + if let Some(value) = value + && !crate::validate::is_valid_ado_url_segment(value) + { + anyhow::bail!( + "{label} '{value}' must contain only [A-Za-z0-9._-] (no \ + leading '.') so it can be embedded in a feed URL without \ + escaping" + ); + } + } + Ok(()) + } +} + /// A pinned Azure DevOps pipeline artifact containing the compiler, AWF, and /// ado-script payloads. #[derive(Debug, Deserialize, Clone)] @@ -2938,6 +3141,9 @@ impl SupplyChainConfig { cannot be accessed with $(System.AccessToken)." ); } + if let Some(packages) = &self.packages { + packages.validate()?; + } Ok(()) } } @@ -4326,6 +4532,127 @@ imports: assert!(sc.validate().is_ok()); } + // ─── supply-chain.packages (shared runtime feed) ───────────────────── + + #[test] + fn test_packages_scalar_shorthand_is_org_scoped() { + let sc = parse_supply_chain("supply-chain:\n packages: my-feed"); + let packages = sc.packages.as_ref().unwrap(); + assert_eq!(packages.feed.as_str(), "my-feed"); + assert_eq!(packages.project_segment(), None); + assert!(packages.applies_to(PackageEcosystem::Python)); + assert!(packages.applies_to(PackageEcosystem::Node)); + assert!(packages.applies_to(PackageEcosystem::Dotnet)); + assert!(sc.validate().is_ok()); + assert_eq!( + packages + .url_for(PackageEcosystem::Node, Some("myorg")) + .unwrap(), + "https://pkgs.dev.azure.com/myorg/_packaging/my-feed/npm/registry/" + ); + } + + #[test] + fn test_packages_project_scoped_urls_per_ecosystem() { + let sc = parse_supply_chain("supply-chain:\n packages:\n feed: my-proj/my-feed"); + let packages = sc.packages.as_ref().unwrap(); + assert_eq!(packages.project_segment(), Some("my-proj")); + let base = "https://pkgs.dev.azure.com/myorg/my-proj/_packaging/my-feed"; + for (ecosystem, expected) in [ + (PackageEcosystem::Python, format!("{base}/pypi/simple/")), + (PackageEcosystem::Node, format!("{base}/npm/registry/")), + ( + PackageEcosystem::Dotnet, + format!("{base}/nuget/v3/index.json"), + ), + ] { + assert_eq!(packages.url_for(ecosystem, Some("myorg")).unwrap(), expected); + } + } + + #[test] + fn test_packages_explicit_organization_overrides_inferred_org() { + let sc = parse_supply_chain( + "supply-chain:\n packages:\n feed: my-feed\n organization: explicit-org\n project: my-proj", + ); + let packages = sc.packages.as_ref().unwrap(); + assert!(sc.validate().is_ok()); + assert_eq!( + packages + .url_for(PackageEcosystem::Python, Some("inferred-org")) + .unwrap(), + "https://pkgs.dev.azure.com/explicit-org/my-proj/_packaging/my-feed/pypi/simple/" + ); + } + + #[test] + fn test_packages_without_org_fails_with_actionable_message() { + let sc = parse_supply_chain("supply-chain:\n packages: my-feed"); + let err = sc + .packages + .as_ref() + .unwrap() + .url_for(PackageEcosystem::Node, None) + .unwrap_err(); + assert!( + err.to_string().contains("packages.organization"), + "expected an actionable message, got: {err}" + ); + } + + #[test] + fn test_packages_per_ecosystem_opt_out() { + let sc = parse_supply_chain( + "supply-chain:\n packages:\n feed: my-feed\n node: false", + ); + let packages = sc.packages.as_ref().unwrap(); + assert!(!packages.applies_to(PackageEcosystem::Node)); + assert!(packages.applies_to(PackageEcosystem::Python)); + assert!(packages.applies_to(PackageEcosystem::Dotnet)); + } + + #[test] + fn test_packages_rejects_project_and_prefixed_feed_conflict() { + let sc = parse_supply_chain( + "supply-chain:\n packages:\n feed: a/my-feed\n project: b", + ); + let err = sc.validate().unwrap_err(); + assert!( + err.to_string().contains("mutually exclusive"), + "got: {err}" + ); + } + + #[test] + fn test_packages_rejects_unsafe_url_segments() { + // Rejected at deserialization by the FeedRef / AdoUrlSegment newtypes. + for yaml in [ + "supply-chain:\n packages: has space", + "supply-chain:\n packages:\n feed: my-feed\n organization: 'bad org'", + "supply-chain:\n packages:\n feed: my-feed\n project: 'a/b'", + "supply-chain:\n packages:\n feed: my-feed\n unknown: true", + ] { + let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let parsed: Result = + serde_yaml::from_value(v["supply-chain"].clone()); + assert!(parsed.is_err(), "expected rejection for: {yaml}"); + } + // Rejected by validate(): a leading '.' passes the FeedRef charset + // but cannot be embedded in a URL segment. + let sc = parse_supply_chain("supply-chain:\n packages: .hidden-feed"); + assert!(sc.validate().is_err()); + } + + #[test] + fn test_packages_is_independent_of_other_supply_chain_targets() { + let sc = parse_supply_chain( + "supply-chain:\n feed: mirror-feed\n packages: pkg-feed\n registry:\n name: myacr.azurecr.io\n service-connection: acr-conn", + ); + assert!(sc.validate().is_ok()); + assert_eq!(sc.feed.as_ref().unwrap().name.as_str(), "mirror-feed"); + assert_eq!(sc.packages.as_ref().unwrap().feed.as_str(), "pkg-feed"); + } + #[test] fn test_supply_chain_feed_only_validates() { // feed-only: registry is None, so validate() never errors regardless of feed. diff --git a/src/runtimes/dotnet/extension.rs b/src/runtimes/dotnet/extension.rs index 7bd08f76d..95f5f3e09 100644 --- a/src/runtimes/dotnet/extension.rs +++ b/src/runtimes/dotnet/extension.rs @@ -5,6 +5,7 @@ use crate::compile::extensions::{CompileContext, CompilerExtension, Declarations use crate::compile::ir::step::{BashStep, Step, TaskStep}; use crate::compile::ir::tasks::nuget_authenticate::NuGetAuthenticate; use crate::compile::ir::tasks::use_dotnet::UseDotNet; +use crate::compile::types::PackageEcosystem; use crate::validate; use anyhow::Result; @@ -78,6 +79,27 @@ impl CompilerExtension for DotnetExtension { validate::validate_feed_url(feed_url, "runtimes.dotnet.feed-url")?; } + // Effective feed resolution (see docs/supply-chain.md): + // 1. runtimes.dotnet.feed-url + // 2. runtimes.dotnet.config (user owns nuget.config — global skipped) + // 3. supply-chain.packages + // 4. public nuget.org + let effective_feed_url: Option = match self.config.feed_url() { + Some(url) => Some(url.to_string()), + None if self.config.config().is_some() => { + if ctx.has_package_feed(PackageEcosystem::Dotnet) { + warnings.push( + "runtimes.dotnet.config is set, so supply-chain.packages is not \ + applied to .NET — the checked-in nuget.config owns the package \ + sources." + .to_string(), + ); + } + None + } + None => ctx.package_feed_url(PackageEcosystem::Dotnet)?, + }; + // Validate version string. Skip the injection check for the // `global.json` sentinel — it's a literal keyword, not a version. if let Some(version) = self.config.version() @@ -115,8 +137,8 @@ impl CompilerExtension for DotnetExtension { let mut agent_prepare_steps: Vec = Vec::with_capacity(3); agent_prepare_steps.push(Step::Task(dotnet_install_task_step(&self.config))); - if self.config.feed_url().is_some() { - agent_prepare_steps.push(Step::Bash(ensure_nuget_config_bash_step(&self.config))); + if let Some(feed_url) = &effective_feed_url { + agent_prepare_steps.push(Step::Bash(ensure_nuget_config_bash_step(feed_url))); agent_prepare_steps.push(Step::Task(nuget_authenticate_task_step())); } else if self.config.config().is_some() { agent_prepare_steps.push(Step::Task(nuget_authenticate_task_step())); @@ -174,10 +196,7 @@ fn nuget_authenticate_task_step() -> TaskStep { /// Build the typed [`BashStep`] that ensures `nuget.config`. Same /// case-variation-aware existence check; same minimal `nuget.config` /// content when the file is missing. -fn ensure_nuget_config_bash_step(config: &DotnetRuntimeConfig) -> BashStep { - let feed_url = config - .feed_url() - .unwrap_or("https://api.nuget.org/v3/index.json"); +fn ensure_nuget_config_bash_step(feed_url: &str) -> BashStep { let script = format!( "set -eo pipefail\n\ if [ ! -f nuget.config ] && [ ! -f NuGet.config ] && [ ! -f NuGet.Config ]; then\n \ @@ -399,4 +418,91 @@ mod tests { other => panic!("expected Step::Task, got {other:?}"), } } + + /// A global `supply-chain.packages` feed alone drives the + /// ensure-nuget.config + authenticate steps. + #[test] + fn declarations_uses_global_package_feed() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n dotnet: true\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let ext = DotnetExtension::new(DotnetRuntimeConfig::Enabled(true)); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 3); + match &decl.agent_prepare_steps[1] { + Step::Bash(b) => assert!( + b.script.contains( + "https://pkgs.dev.azure.com/myorg/_packaging/shared-feed/nuget/v3/index.json" + ), + "expected derived feed in script: {}", + b.script + ), + other => panic!("expected Step::Bash for ensure-nuget, got {other:?}"), + } + match &decl.agent_prepare_steps[2] { + Step::Task(t) => assert_eq!(t.task, "NuGetAuthenticate@1"), + other => panic!("expected NuGetAuthenticate@1, got {other:?}"), + } + } + + /// An explicit per-runtime `feed-url:` wins over the global feed. + #[test] + fn declarations_runtime_feed_url_wins_over_global_package_feed() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n dotnet:\n feed-url: 'https://example.invalid/v3/index.json'\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = DotnetExtension::new(dotnet.clone()); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + match &decl.agent_prepare_steps[1] { + Step::Bash(b) => { + assert!(b.script.contains("https://example.invalid/v3/index.json")); + assert!(!b.script.contains("shared-feed")); + } + other => panic!("expected Step::Bash for ensure-nuget, got {other:?}"), + } + } + + /// Opting .NET out of the global feed leaves the default output. + #[test] + fn declarations_respects_global_package_feed_opt_out() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n dotnet: true\nsupply-chain:\n packages:\n feed: shared-feed\n dotnet: false\n---\n", + ) + .unwrap(); + let ext = DotnetExtension::new(DotnetRuntimeConfig::Enabled(true)); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 1); + } + + /// `config:` keeps ownership of the package sources; the global feed + /// is skipped with a warning and no ensure step is emitted. + #[test] + fn declarations_config_skips_global_package_feed_with_warning() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n dotnet:\n config: 'nuget.config'\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = DotnetExtension::new(dotnet.clone()); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 2); + match &decl.agent_prepare_steps[1] { + Step::Task(t) => assert_eq!(t.task, "NuGetAuthenticate@1"), + other => panic!("expected NuGetAuthenticate@1, got {other:?}"), + } + assert!( + decl.warnings + .iter() + .any(|w| w.contains("supply-chain.packages")), + "expected a skip warning, got: {:?}", + decl.warnings + ); + } } diff --git a/src/runtimes/node/extension.rs b/src/runtimes/node/extension.rs index 98eeb6a3d..b7cb5f0dd 100644 --- a/src/runtimes/node/extension.rs +++ b/src/runtimes/node/extension.rs @@ -5,6 +5,7 @@ use crate::compile::extensions::{CompileContext, CompilerExtension, Declarations use crate::compile::ir::step::{BashStep, Step, TaskStep}; use crate::compile::ir::tasks::npm_authenticate::NpmAuthenticate; use crate::compile::ir::tasks::use_node::UseNode; +use crate::compile::types::PackageEcosystem; use crate::validate; use anyhow::Result; @@ -84,6 +85,26 @@ impl CompilerExtension for NodeExtension { validate::validate_feed_url(feed_url, "runtimes.node.feed-url")?; } + // Effective feed resolution (see docs/supply-chain.md): + // 1. runtimes.node.feed-url + // 2. runtimes.node.config (user owns feed config — global skipped) + // 3. supply-chain.packages + // 4. public npm registry + let effective_feed_url: Option = match self.config.feed_url() { + Some(url) => Some(url.to_string()), + None if self.config.config().is_some() => { + if ctx.has_package_feed(PackageEcosystem::Node) { + warnings.push( + "runtimes.node.config is set, so supply-chain.packages is not \ + applied to Node — the .npmrc file owns the registry." + .to_string(), + ); + } + None + } + None => ctx.package_feed_url(PackageEcosystem::Node)?, + }; + // Validate version string if let Some(version) = self.config.version() { validate::reject_pipeline_injection(version, "runtimes.node.version")?; @@ -91,13 +112,15 @@ impl CompilerExtension for NodeExtension { let mut agent_prepare_steps: Vec = Vec::with_capacity(3); agent_prepare_steps.push(Step::Task(node_install_task_step(&self.config))); - if self.config.feed_url().is_some() || self.config.config().is_some() { - agent_prepare_steps.push(Step::Bash(ensure_npmrc_bash_step(&self.config))); + if effective_feed_url.is_some() || self.config.config().is_some() { + agent_prepare_steps.push(Step::Bash(ensure_npmrc_bash_step( + effective_feed_url.as_deref(), + ))); agent_prepare_steps.push(Step::Task(npm_authenticate_task_step())); } let mut agent_env_vars = Vec::new(); - if let Some(feed_url) = self.config.feed_url() { - agent_env_vars.push(("NPM_CONFIG_REGISTRY".to_string(), feed_url.to_string())); + if let Some(feed_url) = &effective_feed_url { + agent_env_vars.push(("NPM_CONFIG_REGISTRY".to_string(), feed_url.clone())); } Ok(Declarations { agent_prepare_steps, @@ -141,8 +164,8 @@ fn npm_authenticate_task_step() -> TaskStep { /// preserves the legacy semantics: leave any repo-checked-in `.npmrc` /// untouched; otherwise create a minimal one pointing at the /// configured feed (or the default npmjs registry). -fn ensure_npmrc_bash_step(config: &NodeRuntimeConfig) -> BashStep { - let registry = config.feed_url().unwrap_or("https://registry.npmjs.org/"); +fn ensure_npmrc_bash_step(feed_url: Option<&str>) -> BashStep { + let registry = feed_url.unwrap_or("https://registry.npmjs.org/"); let script = format!( "set -eo pipefail\n\ if [ ! -f .npmrc ]; then\n \ @@ -291,4 +314,97 @@ mod tests { .collect(); assert!(keys.contains(&"NPM_CONFIG_REGISTRY")); } + + /// A global `supply-chain.packages` feed alone drives the ensure-npmrc + /// + authenticate steps and `NPM_CONFIG_REGISTRY`. + #[test] + fn declarations_uses_global_package_feed() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n node: true\nsupply-chain:\n packages: proj/shared-feed\n---\n", + ) + .unwrap(); + let ext = NodeExtension::new(NodeRuntimeConfig::Enabled(true)); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 3); + let expected = "https://pkgs.dev.azure.com/myorg/proj/_packaging/shared-feed/npm/registry/"; + match &decl.agent_prepare_steps[1] { + Step::Bash(b) => assert!( + b.script.contains(expected), + "expected derived feed in script: {}", + b.script + ), + other => panic!("expected Step::Bash for ensure-npmrc, got {other:?}"), + } + let value = decl + .agent_env_vars + .iter() + .find(|(k, _)| k == "NPM_CONFIG_REGISTRY") + .map(|(_, v)| v.as_str()); + assert_eq!(value, Some(expected)); + } + + /// An explicit per-runtime `feed-url:` wins over the global feed. + #[test] + fn declarations_runtime_feed_url_wins_over_global_package_feed() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n node:\n feed-url: 'https://example.invalid/registry/'\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let node = fm.runtimes.as_ref().unwrap().node.as_ref().unwrap(); + let ext = NodeExtension::new(node.clone()); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + let value = decl + .agent_env_vars + .iter() + .find(|(k, _)| k == "NPM_CONFIG_REGISTRY") + .map(|(_, v)| v.as_str()); + assert_eq!(value, Some("https://example.invalid/registry/")); + } + + /// Opting Node out of the global feed leaves the default output. + #[test] + fn declarations_respects_global_package_feed_opt_out() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n node: true\nsupply-chain:\n packages:\n feed: shared-feed\n node: false\n---\n", + ) + .unwrap(); + let ext = NodeExtension::new(NodeRuntimeConfig::Enabled(true)); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 1); + assert!(decl.agent_env_vars.is_empty()); + } + + /// `config:` keeps ownership of the registry; the global feed is + /// skipped with a warning and `.npmrc` falls back to public npm. + #[test] + fn declarations_config_skips_global_package_feed_with_warning() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n node:\n config: '.npmrc'\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let node = fm.runtimes.as_ref().unwrap().node.as_ref().unwrap(); + let ext = NodeExtension::new(node.clone()); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 3); + match &decl.agent_prepare_steps[1] { + Step::Bash(b) => assert!( + b.script.contains("https://registry.npmjs.org/"), + "expected public registry fallback: {}", + b.script + ), + other => panic!("expected Step::Bash for ensure-npmrc, got {other:?}"), + } + assert!(decl.agent_env_vars.is_empty()); + assert!( + decl.warnings + .iter() + .any(|w| w.contains("supply-chain.packages")), + "expected a skip warning, got: {:?}", + decl.warnings + ); + } } diff --git a/src/runtimes/python/extension.rs b/src/runtimes/python/extension.rs index 08ce008ed..f38d5eb6d 100644 --- a/src/runtimes/python/extension.rs +++ b/src/runtimes/python/extension.rs @@ -5,6 +5,7 @@ use crate::compile::extensions::{CompileContext, CompilerExtension, Declarations use crate::compile::ir::step::{Step, TaskStep}; use crate::compile::ir::tasks::pip_authenticate::PipAuthenticate; use crate::compile::ir::tasks::use_python_version::UsePythonVersion; +use crate::compile::types::PackageEcosystem; use crate::validate; use anyhow::Result; @@ -83,6 +84,26 @@ impl CompilerExtension for PythonExtension { validate::validate_feed_url(feed_url, "runtimes.python.feed-url")?; } + // Effective feed resolution (see docs/supply-chain.md): + // 1. runtimes.python.feed-url + // 2. runtimes.python.config (user owns feed config — global skipped) + // 3. supply-chain.packages + // 4. public PyPI + let effective_feed_url: Option = match self.config.feed_url() { + Some(url) => Some(url.to_string()), + None if self.config.config().is_some() => { + if ctx.has_package_feed(PackageEcosystem::Python) { + warnings.push( + "runtimes.python.config is set, so supply-chain.packages is not \ + applied to Python — the config file owns the package source." + .to_string(), + ); + } + None + } + None => ctx.package_feed_url(PackageEcosystem::Python)?, + }; + // Validate version string if let Some(version) = self.config.version() { validate::reject_pipeline_injection(version, "runtimes.python.version")?; @@ -90,13 +111,13 @@ impl CompilerExtension for PythonExtension { let mut agent_prepare_steps: Vec = Vec::with_capacity(2); agent_prepare_steps.push(Step::Task(python_install_task_step(&self.config))); - if self.config.feed_url().is_some() { + if effective_feed_url.is_some() { agent_prepare_steps.push(Step::Task(pip_authenticate_task_step())); } let mut agent_env_vars = Vec::new(); - if let Some(feed_url) = self.config.feed_url() { - agent_env_vars.push(("PIP_INDEX_URL".to_string(), feed_url.to_string())); - agent_env_vars.push(("UV_DEFAULT_INDEX".to_string(), feed_url.to_string())); + if let Some(feed_url) = &effective_feed_url { + agent_env_vars.push(("PIP_INDEX_URL".to_string(), feed_url.clone())); + agent_env_vars.push(("UV_DEFAULT_INDEX".to_string(), feed_url.clone())); } Ok(Declarations { agent_prepare_steps, @@ -268,4 +289,87 @@ mod tests { assert!(keys.contains(&"PIP_INDEX_URL")); assert!(keys.contains(&"UV_DEFAULT_INDEX")); } + + /// A global `supply-chain.packages` feed alone drives the auth step + /// and both index env vars, with the URL derived from the ADO org. + #[test] + fn declarations_uses_global_package_feed() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n python: true\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let ext = PythonExtension::new(PythonRuntimeConfig::Enabled(true)); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 2); + match &decl.agent_prepare_steps[1] { + Step::Task(t) => assert_eq!(t.task, "PipAuthenticate@1"), + other => panic!("expected PipAuthenticate@1, got {other:?}"), + } + let expected = "https://pkgs.dev.azure.com/myorg/_packaging/shared-feed/pypi/simple/"; + for key in ["PIP_INDEX_URL", "UV_DEFAULT_INDEX"] { + let value = decl + .agent_env_vars + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()); + assert_eq!(value, Some(expected), "unexpected value for {key}"); + } + } + + /// An explicit per-runtime `feed-url:` wins over the global feed. + #[test] + fn declarations_runtime_feed_url_wins_over_global_package_feed() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n python:\n feed-url: 'https://example.invalid/simple/'\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let python = fm.runtimes.as_ref().unwrap().python.as_ref().unwrap(); + let ext = PythonExtension::new(python.clone()); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + let value = decl + .agent_env_vars + .iter() + .find(|(k, _)| k == "PIP_INDEX_URL") + .map(|(_, v)| v.as_str()); + assert_eq!(value, Some("https://example.invalid/simple/")); + } + + /// Opting Python out of the global feed leaves the default output. + #[test] + fn declarations_respects_global_package_feed_opt_out() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n python: true\nsupply-chain:\n packages:\n feed: shared-feed\n python: false\n---\n", + ) + .unwrap(); + let ext = PythonExtension::new(PythonRuntimeConfig::Enabled(true)); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 1); + assert!(decl.agent_env_vars.is_empty()); + } + + /// `config:` keeps ownership of the package source; the global feed + /// is skipped with a warning rather than silently layered on top. + #[test] + fn declarations_config_skips_global_package_feed_with_warning() { + let (fm, _) = parse_markdown( + "---\nname: t\ndescription: x\nruntimes:\n python:\n config: 'pip.conf'\nsupply-chain:\n packages: shared-feed\n---\n", + ) + .unwrap(); + let python = fm.runtimes.as_ref().unwrap().python.as_ref().unwrap(); + let ext = PythonExtension::new(python.clone()); + let ctx = CompileContext::for_test_with_org(&fm, "myorg"); + let decl = ext.declarations(&ctx).unwrap(); + assert_eq!(decl.agent_prepare_steps.len(), 1); + assert!(decl.agent_env_vars.is_empty()); + assert!( + decl.warnings + .iter() + .any(|w| w.contains("supply-chain.packages")), + "expected a skip warning, got: {:?}", + decl.warnings + ); + } } diff --git a/src/secure.rs b/src/secure.rs index b89b6682c..71b0680f8 100644 --- a/src/secure.rs +++ b/src/secure.rs @@ -34,6 +34,7 @@ //! - [`HostName`] — a DNS-style hostname. //! - [`RegistryRef`] — a container-registry host or base path. //! - [`AdoProject`] — an Azure DevOps project name or GUID. +//! - [`AdoUrlSegment`] — an ADO org/project name safe to embed in a URL. //! - [`Version`] — a version string (`1.2.3`, `latest`). //! //! New safe-output tools that accept paths or identifiers should type those @@ -367,6 +368,23 @@ validated_string! { } } +validated_string! { + /// An Azure DevOps organization or project name that is safe to embed + /// directly in a derived feed URL (no percent-encoding is applied). + AdoUrlSegment, "name", |value: &str, label: &str| { + if validate::is_valid_ado_url_segment(value) { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be an Azure DevOps organization or \ + project name containing only [A-Za-z0-9._-] (no spaces, no \ + '/', no leading '.') so it can be embedded in a feed URL \ + without escaping" + ) + } + } +} + validated_string! { /// An Azure DevOps project name or GUID. AdoProject, "project", |value: &str, label: &str| { diff --git a/src/validate.rs b/src/validate.rs index 7960639b7..685fcd7f6 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -42,6 +42,17 @@ pub fn is_safe_path_segment(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) } +/// Validate that a string is safe to embed as a single Azure DevOps URL +/// segment (an organization or project name inside a derived feed URL). +/// +/// Uses the same strict `[A-Za-z0-9._-]` allowlist as +/// [`is_safe_path_segment`]: no percent-encoding is performed on derived +/// URLs, so anything requiring escaping (notably spaces) is rejected rather +/// than silently producing a malformed URL. +pub fn is_valid_ado_url_segment(s: &str) -> bool { + is_safe_path_segment(s) +} + /// Characters allowed in engine.command paths (absolute path chars only). /// Prevents shell injection when the path is embedded in AWF single-quoted commands. pub fn is_valid_command_path(s: &str) -> bool { diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 4aa535fe9..0aace74bd 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -7942,6 +7942,152 @@ fn test_supply_chain_absent_uses_github_and_ghcr() { ); } +/// `supply-chain.packages` supplies one feed identity that all three +/// language runtimes derive their ecosystem-specific endpoint from. +#[test] +fn test_supply_chain_packages_feeds_all_runtimes() { + let source = r#"--- +name: "Shared Package Feed" +description: "one feed identity for python, node and dotnet" +runtimes: + python: true + node: true + dotnet: true +supply-chain: + packages: + feed: my-feed + organization: myorg + project: my-proj +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("packages-all-runtimes", source); + assert!(ok, "shared package feed should compile: {stderr}"); + let base = "https://pkgs.dev.azure.com/myorg/my-proj/_packaging/my-feed"; + for expected in [ + format!("{base}/pypi/simple/"), + format!("{base}/npm/registry/"), + format!("{base}/nuget/v3/index.json"), + ] { + assert!( + compiled.contains(&expected), + "compiled pipeline must carry the derived endpoint {expected}" + ); + } + for task in [ + "- task: PipAuthenticate@1", + "- task: npmAuthenticate@0", + "- task: NuGetAuthenticate@1", + ] { + assert!( + compiled.contains(task), + "{task} must be emitted for the shared package feed" + ); + } + for env_var in ["PIP_INDEX_URL", "UV_DEFAULT_INDEX", "NPM_CONFIG_REGISTRY"] { + assert!( + compiled.contains(env_var), + "{env_var} must be injected for the shared package feed" + ); + } +} + +/// A per-runtime `feed-url:` still wins over `supply-chain.packages`, and an +/// ecosystem can opt out entirely. +#[test] +fn test_supply_chain_packages_precedence_and_opt_out() { + let source = r#"--- +name: "Package Feed Precedence" +description: "per-runtime override plus an opt-out" +runtimes: + python: + feed-url: "https://pkgs.dev.azure.com/otherorg/_packaging/pinned/pypi/simple/" + node: true +supply-chain: + packages: + feed: my-feed + organization: myorg + node: false +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("packages-precedence", source); + assert!(ok, "package feed precedence should compile: {stderr}"); + assert!( + compiled.contains("https://pkgs.dev.azure.com/otherorg/_packaging/pinned/pypi/simple/"), + "the per-runtime feed-url must win for Python" + ); + assert!( + !compiled.contains("_packaging/my-feed/pypi/simple/"), + "the shared feed must not also be applied to Python" + ); + assert!( + !compiled.contains("_packaging/my-feed/npm/registry/"), + "Node opted out of the shared feed" + ); + assert!( + !compiled.contains("- task: npmAuthenticate@0"), + "an opted-out ecosystem must keep its default (public) configuration" + ); +} + +/// Without an inferable ADO organization (and no override) the shared feed +/// fails closed with an actionable message rather than emitting a bad URL. +#[test] +fn test_supply_chain_packages_requires_resolvable_organization() { + let source = r#"--- +name: "Package Feed No Org" +description: "no organization override and no ADO remote" +runtimes: + python: true +supply-chain: + packages: my-feed +--- + +## Body +"#; + let (ok, _compiled, stderr) = compile_inline_source("packages-no-org", source); + assert!(!ok, "compilation must fail when the org cannot be resolved"); + assert!( + stderr.contains("supply-chain.packages"), + "error must name the offending front-matter key, got: {stderr}" + ); +} + +/// A runtime that owns its own package sources via `config:` defers to that +/// file, so an unresolvable `supply-chain.packages` org must not be resolved +/// (and must not fail compilation) on that diagnostic-only path. +#[test] +fn test_supply_chain_packages_skipped_for_config_owned_runtime() { + let source = r#"--- +name: "Package Feed Config Owned" +description: "nuget.config owns the sources, no organization is inferable" +runtimes: + dotnet: + config: "nuget.config" +supply-chain: + packages: my-feed +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("packages-config-owned", source); + assert!( + ok, + "config-owned runtime must not resolve supply-chain.packages, got: {stderr}" + ); + assert!( + !compiled.contains("my-feed"), + "the shared feed must not be applied when config: owns the sources" + ); + assert!( + stderr.contains("runtimes.dotnet.config is set"), + "a warning must explain that supply-chain.packages is skipped, got: {stderr}" + ); +} + /// `feed` only (scalar, same-org) mirrors binaries via `$(System.AccessToken)` /// — no `nuGetServiceConnections` — and leaves images on GHCR. #[test]