flowey_lib_common/nuget_install_package.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Download NuGet packages using `dotnet restore` with a synthetic `.csproj`.
5//!
6//! On CI (ADO/GitHub), relies on ambient pipeline credentials (set by
7//! `NuGetAuthenticate@1` or equivalent).
8//! Locally, uses `az account get-access-token` to obtain an Azure DevOps
9//! bearer token, exchanges it for a session token via the Azure DevOps
10//! REST API, and passes it to the NuGet credential provider via the
11//! `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS` environment variable.
12
13use flowey::node::prelude::*;
14use std::collections::BTreeMap;
15
16#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Debug)]
17pub struct NugetPackage {
18 pub id: String,
19 pub version: String,
20}
21
22flowey_request! {
23 pub enum Request {
24 /// A bundle of packages to install in one dotnet restore invocation
25 Install {
26 /// Path to a nuget.config file
27 nuget_config_file: ReadVar<PathBuf>,
28 /// A list of nuget packages to install, and outvars denoting where they
29 /// were extracted to.
30 packages: Vec<(ReadVar<NugetPackage>, WriteVar<PathBuf>)>,
31 /// Directory to install the packages into.
32 install_dir: ReadVar<PathBuf>,
33 /// Side effects that must have run before installing these packages.
34 ///
35 /// e.g: requiring that a nuget credentials manager has been installed
36 pre_install_side_effects: Vec<ReadVar<SideEffect>>,
37 },
38 }
39}
40
41struct InstallRequest {
42 nuget_config_file: ReadVar<PathBuf>,
43 packages: Vec<(ReadVar<NugetPackage>, WriteVar<PathBuf>)>,
44 install_dir: ReadVar<PathBuf>,
45 pre_install_side_effects: Vec<ReadVar<SideEffect>>,
46}
47
48new_flow_node!(struct Node);
49
50impl FlowNode for Node {
51 type Request = Request;
52
53 fn imports(ctx: &mut ImportCtx<'_>) {
54 ctx.import::<super::install_dotnet_cli::Node>();
55 }
56
57 fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
58 let mut install = Vec::new();
59
60 for request in requests {
61 match request {
62 Request::Install {
63 packages,
64 nuget_config_file,
65 install_dir,
66 pre_install_side_effects,
67 } => install.push(InstallRequest {
68 packages,
69 nuget_config_file,
70 install_dir,
71 pre_install_side_effects,
72 }),
73 }
74 }
75
76 // -- end of req processing -- //
77
78 if install.is_empty() {
79 return Ok(());
80 }
81
82 Self::emit_dotnet_restore(ctx, install)
83 }
84}
85
86impl Node {
87 /// Use `dotnet restore` with a synthetic `.csproj` containing
88 /// `PackageDownload` items.
89 ///
90 /// On Local, obtains a session token from `az` CLI and passes it
91 /// to the credential provider via `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS`.
92 /// On CI, relies on ambient pipeline credentials (set by
93 /// `NuGetAuthenticate@1` or equivalent).
94 fn emit_dotnet_restore(
95 ctx: &mut NodeCtx<'_>,
96 install: Vec<InstallRequest>,
97 ) -> anyhow::Result<()> {
98 let dotnet_bin = ctx.reqv(super::install_dotnet_cli::Request::DotnetBin);
99
100 for InstallRequest {
101 packages,
102 nuget_config_file,
103 install_dir,
104 pre_install_side_effects,
105 } in install
106 {
107 ctx.emit_rust_step("restore nuget packages", |ctx| {
108 let dotnet_bin = dotnet_bin.clone().claim(ctx);
109 let install_dir = install_dir.claim(ctx);
110 pre_install_side_effects.claim(ctx);
111
112 let packages = packages
113 .into_iter()
114 .map(|(a, b)| (a.claim(ctx), b.claim(ctx)))
115 .collect::<Vec<_>>();
116 let nuget_config_file = nuget_config_file.claim(ctx);
117
118 move |rt| {
119 let dotnet_bin = rt.read(dotnet_bin);
120 let nuget_config_file = rt.read(nuget_config_file);
121 let install_dir = rt.read(install_dir);
122
123 let packages = {
124 let mut pkgmap: BTreeMap<_, Vec<_>> = BTreeMap::new();
125 for (package, var) in packages {
126 pkgmap.entry(rt.read(package)).or_default().push(var);
127 }
128 pkgmap
129 };
130
131 // Generate a synthetic .csproj with PackageDownload items.
132 // PackageDownload downloads the exact nupkg without resolving
133 // transitive dependencies — this is intentional, as these
134 // packages are standalone native binaries / firmware blobs
135 // that do not have NuGet transitive dependencies.
136 let csproj_content = {
137 let items: String = packages
138 .keys()
139 .map(|NugetPackage { id, version }| {
140 format!(
141 r#" <PackageDownload Include="{id}" Version="[{version}]" />"#
142 )
143 })
144 .collect::<Vec<_>>()
145 .join("\n");
146
147 format!(
148r#"<Project Sdk="Microsoft.NET.Sdk">
149 <PropertyGroup>
150 <TargetFramework>net8.0</TargetFramework>
151 </PropertyGroup>
152 <ItemGroup>
153{items}
154 </ItemGroup>
155</Project>
156"#
157 )
158 };
159
160 log::debug!("generated .csproj:\n{}", csproj_content);
161
162 // Write the synthetic project to a unique temp directory
163 // so we don't pollute the repo and avoid collisions
164 // with concurrent runs.
165 //
166 // NOTE: After the restore, packages are *moved* out of
167 // this directory into `install_dir`. When `restore_work_dir`
168 // is dropped it will attempt to remove the (now partially
169 // empty) tree — this is harmless and intentional.
170 let restore_work_dir = tempfile::tempdir()?;
171 let restore_work_dir_path = restore_work_dir.path();
172
173 let csproj_path = restore_work_dir_path.join("NuGetRestore.csproj");
174 fs_err::write(&csproj_path, csproj_content)?;
175
176 let restore_packages_dir = restore_work_dir_path.join("packages");
177 fs_err::create_dir_all(&restore_packages_dir)?;
178
179 // Copy the nuget.config alongside the .csproj so dotnet
180 // picks it up automatically, filtering out the
181 // packages.config-era `repositoryPath` setting
182 // that lives under `<config>` and conflicts with
183 // the `--packages` flag we pass to `dotnet restore`.
184 let local_nuget_config = restore_work_dir_path.join("nuget.config");
185 let config_content = fs_err::read_to_string(&nuget_config_file)?;
186 let parsed = parse_nuget_config(&config_content)?;
187 fs_err::write(&local_nuget_config, &parsed.filtered_config)?;
188
189 // On the Local backend, obtain an Azure DevOps session
190 // token from `az` CLI and pass it to the credential
191 // provider via the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS
192 // env var (the same mechanism ADO CI uses).
193 let feed_endpoints_json = if matches!(rt.backend(), FlowBackend::Local) {
194 get_feed_endpoints_json(rt, parsed.feed_urls)?
195 } else {
196 None
197 };
198
199 let mut cmd = flowey::shell_cmd!(
200 rt,
201 "{dotnet_bin} restore {csproj_path} --packages {restore_packages_dir} --configfile {local_nuget_config}"
202 );
203 if let Some(json) = &feed_endpoints_json {
204 cmd = cmd.env("VSS_NUGET_EXTERNAL_FEED_ENDPOINTS", json);
205 }
206 cmd.run()?;
207
208 // Post-process: flatten from the dotnet restore layout
209 // ({id_lower}/{version}/) into the expected layout
210 // ({original_case_id}/) in install_dir.
211 //
212 // dotnet restore stores packages with lowercased IDs, but
213 // downstream code expects original-case directory names.
214 fs_err::create_dir_all(&install_dir)?;
215
216 for (package, package_out_dir) in packages {
217 let pkg_id_lower = package.id.to_lowercase();
218 let version_lower = package.version.to_lowercase();
219 let src_dir = restore_packages_dir
220 .join(&pkg_id_lower)
221 .join(&version_lower);
222
223 let dest_dir = install_dir.join(&package.id);
224
225 if dest_dir.exists() {
226 // Remove any previous version.
227 fs_err::remove_dir_all(&dest_dir)?;
228 }
229
230 if src_dir.exists() {
231 move_dir(&src_dir, &dest_dir)?;
232 } else {
233 anyhow::bail!(
234 "Package '{}' version '{}' was not found in restore output at '{}'",
235 package.id,
236 package.version,
237 src_dir.display()
238 );
239 }
240
241 let dest_abs = dest_dir.absolute()?;
242 for var in package_out_dir {
243 rt.write(var, &dest_abs);
244 }
245 }
246
247 Ok(())
248 }
249 });
250 }
251
252 Ok(())
253 }
254}
255
256/// Parsed nuget.config with `repositoryPath` entries removed and
257/// feed URLs extracted.
258struct ParsedNugetConfig {
259 /// The nuget.config content with `<add key="repositoryPath" …/>`
260 /// entries under `<config>` removed.
261 filtered_config: String,
262 /// Feed URLs from `<packageSources>`.
263 feed_urls: Vec<String>,
264}
265
266/// Parse a nuget.config file, stripping `repositoryPath` settings from
267/// `<config>` sections (they conflict with `dotnet restore --packages`)
268/// and extracting feed URLs from `<packageSources>`.
269fn parse_nuget_config(config_content: &str) -> anyhow::Result<ParsedNugetConfig> {
270 let doc = roxmltree::Document::parse(config_content)
271 .map_err(|e| anyhow::anyhow!("failed to parse nuget.config: {e}"))?;
272
273 // Find lines containing `<add key="repositoryPath" …/>`
274 // that are direct children of a `<config>` element.
275 let lines_to_remove: std::collections::HashSet<usize> = doc
276 .descendants()
277 .filter(|node| {
278 node.tag_name().name() == "add"
279 && node
280 .parent()
281 .is_some_and(|p| p.tag_name().name() == "config")
282 && node
283 .attribute("key")
284 .is_some_and(|k| k.eq_ignore_ascii_case("repositorypath"))
285 })
286 .map(|node| {
287 // Convert byte offset to 0-based line index.
288 config_content[..node.range().start]
289 .bytes()
290 .filter(|&b| b == b'\n')
291 .count()
292 })
293 .collect();
294
295 let feed_urls: Vec<String> = doc
296 .descendants()
297 .filter(|node| {
298 node.tag_name().name() == "add"
299 && node
300 .parent()
301 .is_some_and(|p| p.tag_name().name() == "packageSources")
302 })
303 .filter_map(|node| node.attribute("value").map(String::from))
304 .collect();
305
306 let filtered_config = if lines_to_remove.is_empty() {
307 config_content.to_owned()
308 } else {
309 config_content
310 .lines()
311 .enumerate()
312 .filter(|(i, _)| !lines_to_remove.contains(i))
313 .map(|(_, line)| line)
314 .collect::<Vec<_>>()
315 .join("\n")
316 };
317
318 Ok(ParsedNugetConfig {
319 filtered_config,
320 feed_urls,
321 })
322}
323
324/// Obtain an Azure DevOps session token via `az` CLI and build the
325/// `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS` JSON for the credential provider.
326///
327/// This uses the same env var that ADO's `NuGetAuthenticate@1` task sets
328/// in CI pipelines — the credential provider reads it and supplies the
329/// credentials to `dotnet restore` transparently.
330///
331/// Why not just let the credential provider authenticate interactively?
332/// Because many orgs enforce Conditional Access Policies that block MSAL
333/// interactive auth from non-compliant devices (like WSL). The `az` CLI
334/// works because it runs on the Windows host (via WSL interop), which is
335/// already authenticated and compliant.
336///
337/// The flow:
338/// 1. `az account get-access-token --resource 499b84ac-...` → JWT bearer
339/// 2. Exchange the JWT for a session token via the Azure DevOps REST API
340/// 3. Build the `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS` JSON with the token
341///
342/// Returns `None` if no Azure DevOps feeds are found in the nuget.config.
343fn get_feed_endpoints_json(
344 rt: &mut RustRuntimeServices<'_>,
345 feed_urls: Vec<String>,
346) -> anyhow::Result<Option<String>> {
347 // Filter to Azure DevOps feeds first — avoid requiring az/curl when the
348 // config only contains public or third-party feeds (e.g. nuget.org).
349 let ado_feeds: Vec<String> = feed_urls
350 .into_iter()
351 .filter(|url| is_azure_devops_feed(url))
352 .collect();
353
354 if ado_feeds.is_empty() {
355 log::info!("no Azure DevOps feeds found in nuget.config, skipping auth");
356 return Ok(None);
357 }
358
359 // Resolve the `az` CLI binary. We use `which` instead of a bare "az"
360 // because on Windows the CLI is installed as `az.cmd` and Rust's
361 // Command does not consult PATHEXT to find it.
362 let az_cli_bin = which::which("az").map_err(|_| {
363 anyhow::anyhow!(
364 "`az` CLI not found on PATH. \
365 Install the Azure CLI and run `az login` to authenticate."
366 )
367 })?;
368
369 // 1. Get a bearer token from az CLI.
370 // The resource ID 499b84ac-1321-427f-aa17-267ca6975798 is Azure DevOps.
371 // The output contains a credential, so mark the command as secret to
372 // prevent it from appearing in process listings / logs.
373 let bearer_token = flowey::shell_cmd!(
374 rt,
375 "{az_cli_bin} account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv"
376 )
377 .secret()
378 .read()
379 .map_err(|e| anyhow::anyhow!(
380 "failed to get Azure DevOps access token from `az` CLI. \
381 Ensure you are logged in with `az login`. Error: {e}"
382 ))?;
383
384 if bearer_token.is_empty() {
385 anyhow::bail!(
386 "az CLI returned an empty access token. \
387 Ensure you are logged in with `az login`."
388 );
389 }
390
391 // 2. Exchange the bearer token for a short-lived session token.
392 // Session tokens work with NuGet's Basic auth (unlike JWT bearer tokens).
393 let session_token_body = serde_json::json!({
394 "scope": "vso.packaging",
395 "displayName": "flowey-nuget-restore",
396 });
397
398 // Pass the Authorization header via stdin (`-K -`) so the bearer
399 // token never appears in process argument lists (visible via `ps`).
400 let session_response = flowey::shell_cmd!(
401 rt,
402 "curl -s --fail -X POST https://app.vssps.visualstudio.com/_apis/token/sessiontokens?api-version=5.0-preview.1 -H Content-Type:application/json -K -"
403 )
404 .stdin(format!("header = \"Authorization: Bearer {bearer_token}\""))
405 .arg("-d")
406 .arg(session_token_body.to_string())
407 .secret()
408 .read()
409 .map_err(|e| anyhow::anyhow!("failed to exchange bearer token for session token: {e}"))?;
410
411 let session_json: serde_json::Value = serde_json::from_str(&session_response)
412 .map_err(|_| anyhow::anyhow!("failed to parse session token response from Azure DevOps"))?;
413
414 let session_token = session_json["token"]
415 .as_str()
416 .ok_or_else(|| anyhow::anyhow!("session token response missing 'token' field"))?;
417
418 log::info!("obtained Azure DevOps session token for nuget auth");
419
420 // 3. Build the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS JSON.
421 // This is the same format that NuGetAuthenticate@1 uses in ADO CI.
422 let endpoints: Vec<serde_json::Value> = ado_feeds
423 .iter()
424 .map(|url| {
425 serde_json::json!({
426 "endpoint": url,
427 "username": "AzureDevOps",
428 "password": session_token,
429 })
430 })
431 .collect();
432
433 let feed_json = serde_json::json!({
434 "endpointCredentials": endpoints,
435 })
436 .to_string();
437
438 Ok(Some(feed_json))
439}
440
441/// Move a directory, falling back to recursive copy + delete if rename fails
442/// (e.g. across filesystem boundaries where rename returns EXDEV).
443fn move_dir(src: &Path, dest: &Path) -> anyhow::Result<()> {
444 match fs_err::rename(src, dest) {
445 Ok(()) => Ok(()),
446 Err(e) => {
447 // rename(2) fails with EXDEV (errno 18 on Linux, error 17 on
448 // Windows) when src and dest are on different filesystems.
449 // Fall back to a recursive copy + delete.
450 log::debug!(
451 "rename failed ({}), falling back to copy+delete for {}",
452 e,
453 src.display()
454 );
455 crate::_util::copy_dir_all(src, dest)?;
456 fs_err::remove_dir_all(src)?;
457 Ok(())
458 }
459 }
460}
461
462/// Check whether a feed URL is an Azure DevOps Artifacts feed.
463fn is_azure_devops_feed(url: &str) -> bool {
464 let lower = url.to_lowercase();
465 lower.contains("pkgs.dev.azure.com") || lower.contains(".pkgs.visualstudio.com")
466}