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 //
137 // The project is never compiled, so all implicit framework
138 // references / targeting + runtime pack downloads are
139 // disabled. Without this, an SDK newer than the
140 // `TargetFramework` below would try to restore the matching
141 // targeting packs (e.g. `Microsoft.NETCore.App.Ref`) from
142 // the configured feeds, which typically don't mirror them.
143 let csproj_content = {
144 let items: String = packages
145 .keys()
146 .map(|NugetPackage { id, version }| {
147 format!(
148 r#" <PackageDownload Include="{id}" Version="[{version}]" />"#
149 )
150 })
151 .collect::<Vec<_>>()
152 .join("\n");
153
154 format!(
155r#"<Project Sdk="Microsoft.NET.Sdk">
156 <PropertyGroup>
157 <TargetFramework>net8.0</TargetFramework>
158 <DisableImplicitFrameworkReferences>true</DisableImplicitFrameworkReferences>
159 <EnableTargetingPackDownload>false</EnableTargetingPackDownload>
160 <EnableRuntimePackDownload>false</EnableRuntimePackDownload>
161 <EnableAppHostPackDownload>false</EnableAppHostPackDownload>
162 </PropertyGroup>
163 <ItemGroup>
164{items}
165 </ItemGroup>
166</Project>
167"#
168 )
169 };
170
171 log::debug!("generated .csproj:\n{}", csproj_content);
172
173 // Write the synthetic project to a unique temp directory
174 // so we don't pollute the repo and avoid collisions
175 // with concurrent runs.
176 //
177 // NOTE: After the restore, packages are *moved* out of
178 // this directory into `install_dir`. When `restore_work_dir`
179 // is dropped it will attempt to remove the (now partially
180 // empty) tree — this is harmless and intentional.
181 let restore_work_dir = tempfile::tempdir()?;
182 let restore_work_dir_path = restore_work_dir.path();
183
184 let csproj_path = restore_work_dir_path.join("NuGetRestore.csproj");
185 fs_err::write(&csproj_path, csproj_content)?;
186
187 let restore_packages_dir = restore_work_dir_path.join("packages");
188 fs_err::create_dir_all(&restore_packages_dir)?;
189
190 // Copy the nuget.config alongside the .csproj so dotnet
191 // picks it up automatically, filtering out the
192 // packages.config-era `repositoryPath` setting
193 // that lives under `<config>` and conflicts with
194 // the `--packages` flag we pass to `dotnet restore`.
195 let local_nuget_config = restore_work_dir_path.join("nuget.config");
196 let config_content = fs_err::read_to_string(&nuget_config_file)?;
197 let parsed = parse_nuget_config(&config_content)?;
198 fs_err::write(&local_nuget_config, &parsed.filtered_config)?;
199
200 // On the Local backend, obtain an Azure DevOps session
201 // token from `az` CLI and pass it to the credential
202 // provider via the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS
203 // env var (the same mechanism ADO CI uses).
204 let feed_endpoints_json = if matches!(rt.backend(), FlowBackend::Local) {
205 get_feed_endpoints_json(rt, parsed.feed_urls)?
206 } else {
207 None
208 };
209
210 let mut cmd = flowey::shell_cmd!(
211 rt,
212 "{dotnet_bin} restore {csproj_path} --packages {restore_packages_dir} --configfile {local_nuget_config}"
213 );
214 if let Some(json) = &feed_endpoints_json {
215 cmd = cmd.env("VSS_NUGET_EXTERNAL_FEED_ENDPOINTS", json);
216 }
217 if let Err(e) = cmd.run() {
218 if matches!(rt.backend(), FlowBackend::Local) {
219 if feed_endpoints_json.is_some() {
220 log::error!(
221 "HINT: NuGet restore failed while using Azure DevOps feeds. \
222 You may need to install the Azure Artifacts Credential \
223 Provider and/or log in with `az login` to refresh your \
224 credentials."
225 );
226 } else {
227 log::error!(
228 "HINT: NuGet restore failed. Check the restore output above \
229 for details and ensure your NuGet feeds are accessible from \
230 this environment."
231 );
232 }
233 }
234 return Err(e.into());
235 }
236
237 // Post-process: flatten from the dotnet restore layout
238 // ({id_lower}/{version}/) into the expected layout
239 // ({original_case_id}/) in install_dir.
240 //
241 // dotnet restore stores packages with lowercased IDs, but
242 // downstream code expects original-case directory names.
243 fs_err::create_dir_all(&install_dir)?;
244
245 for (package, package_out_dir) in packages {
246 let pkg_id_lower = package.id.to_lowercase();
247 let version_lower = package.version.to_lowercase();
248 let src_dir = restore_packages_dir
249 .join(&pkg_id_lower)
250 .join(&version_lower);
251
252 let dest_dir = install_dir.join(&package.id);
253
254 if dest_dir.exists() {
255 // Remove any previous version.
256 fs_err::remove_dir_all(&dest_dir)?;
257 }
258
259 if src_dir.exists() {
260 move_dir(&src_dir, &dest_dir)?;
261 } else {
262 anyhow::bail!(
263 "Package '{}' version '{}' was not found in restore output at '{}'",
264 package.id,
265 package.version,
266 src_dir.display()
267 );
268 }
269
270 let dest_abs = dest_dir.absolute()?;
271 for var in package_out_dir {
272 rt.write(var, &dest_abs);
273 }
274 }
275
276 Ok(())
277 }
278 });
279 }
280
281 Ok(())
282 }
283}
284
285/// Parsed nuget.config with `repositoryPath` entries removed and
286/// feed URLs extracted.
287struct ParsedNugetConfig {
288 /// The nuget.config content with `<add key="repositoryPath" …/>`
289 /// entries under `<config>` removed.
290 filtered_config: String,
291 /// Feed URLs from `<packageSources>`.
292 feed_urls: Vec<String>,
293}
294
295/// Parse a nuget.config file, stripping `repositoryPath` settings from
296/// `<config>` sections (they conflict with `dotnet restore --packages`)
297/// and extracting feed URLs from `<packageSources>`.
298fn parse_nuget_config(config_content: &str) -> anyhow::Result<ParsedNugetConfig> {
299 let doc = roxmltree::Document::parse(config_content)
300 .map_err(|e| anyhow::anyhow!("failed to parse nuget.config: {e}"))?;
301
302 // Find lines containing `<add key="repositoryPath" …/>`
303 // that are direct children of a `<config>` element.
304 let lines_to_remove: std::collections::HashSet<usize> = doc
305 .descendants()
306 .filter(|node| {
307 node.tag_name().name() == "add"
308 && node
309 .parent()
310 .is_some_and(|p| p.tag_name().name() == "config")
311 && node
312 .attribute("key")
313 .is_some_and(|k| k.eq_ignore_ascii_case("repositorypath"))
314 })
315 .map(|node| {
316 // Convert byte offset to 0-based line index.
317 config_content[..node.range().start]
318 .bytes()
319 .filter(|&b| b == b'\n')
320 .count()
321 })
322 .collect();
323
324 let feed_urls: Vec<String> = doc
325 .descendants()
326 .filter(|node| {
327 node.tag_name().name() == "add"
328 && node
329 .parent()
330 .is_some_and(|p| p.tag_name().name() == "packageSources")
331 })
332 .filter_map(|node| node.attribute("value").map(String::from))
333 .collect();
334
335 let filtered_config = if lines_to_remove.is_empty() {
336 config_content.to_owned()
337 } else {
338 config_content
339 .lines()
340 .enumerate()
341 .filter(|(i, _)| !lines_to_remove.contains(i))
342 .map(|(_, line)| line)
343 .collect::<Vec<_>>()
344 .join("\n")
345 };
346
347 Ok(ParsedNugetConfig {
348 filtered_config,
349 feed_urls,
350 })
351}
352
353/// Obtain an Azure DevOps session token via `az` CLI and build the
354/// `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS` JSON for the credential provider.
355///
356/// This uses the same env var that ADO's `NuGetAuthenticate@1` task sets
357/// in CI pipelines — the credential provider reads it and supplies the
358/// credentials to `dotnet restore` transparently.
359///
360/// Why not just let the credential provider authenticate interactively?
361/// Because many orgs enforce Conditional Access Policies that block MSAL
362/// interactive auth from non-compliant devices (like WSL). The `az` CLI
363/// works because it runs on the Windows host (via WSL interop), which is
364/// already authenticated and compliant.
365///
366/// The flow:
367/// 1. `az account get-access-token --resource 499b84ac-...` → JWT bearer
368/// 2. Exchange the JWT for a session token via the Azure DevOps REST API
369/// 3. Build the `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS` JSON with the token
370///
371/// Returns `None` if no Azure DevOps feeds are found in the nuget.config.
372fn get_feed_endpoints_json(
373 rt: &mut RustRuntimeServices<'_>,
374 feed_urls: Vec<String>,
375) -> anyhow::Result<Option<String>> {
376 // Filter to Azure DevOps feeds first — avoid requiring az/curl when the
377 // config only contains public or third-party feeds (e.g. nuget.org).
378 let ado_feeds: Vec<String> = feed_urls
379 .into_iter()
380 .filter(|url| is_azure_devops_feed(url))
381 .collect();
382
383 if ado_feeds.is_empty() {
384 log::info!("no Azure DevOps feeds found in nuget.config, skipping auth");
385 return Ok(None);
386 }
387
388 // Resolve the `az` CLI binary. We use `which` instead of a bare "az"
389 // because on Windows the CLI is installed as `az.cmd` and Rust's
390 // Command does not consult PATHEXT to find it.
391 let az_cli_bin = which::which("az").map_err(|_| {
392 anyhow::anyhow!(
393 "`az` CLI not found on PATH. \
394 Install the Azure CLI and run `az login` to authenticate."
395 )
396 })?;
397
398 // 1. Get a bearer token from az CLI.
399 // The resource ID 499b84ac-1321-427f-aa17-267ca6975798 is Azure DevOps.
400 // The output contains a credential, so mark the command as secret to
401 // prevent it from appearing in process listings / logs.
402 let bearer_token = flowey::shell_cmd!(
403 rt,
404 "{az_cli_bin} account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv"
405 )
406 .secret()
407 .read()
408 .map_err(|e| anyhow::anyhow!(
409 "failed to get Azure DevOps access token from `az` CLI. \
410 Ensure you are logged in with `az login`. Error: {e}"
411 ))?;
412
413 if bearer_token.is_empty() {
414 anyhow::bail!(
415 "az CLI returned an empty access token. \
416 Ensure you are logged in with `az login`."
417 );
418 }
419
420 // 2. Exchange the bearer token for a short-lived session token.
421 // Session tokens work with NuGet's Basic auth (unlike JWT bearer tokens).
422 let session_token_body = serde_json::json!({
423 "scope": "vso.packaging",
424 "displayName": "flowey-nuget-restore",
425 });
426
427 // Pass the Authorization header via stdin (`-K -`) so the bearer
428 // token never appears in process argument lists (visible via `ps`).
429 let session_response = flowey::shell_cmd!(
430 rt,
431 "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 -"
432 )
433 .stdin(format!("header = \"Authorization: Bearer {bearer_token}\""))
434 .arg("-d")
435 .arg(session_token_body.to_string())
436 .secret()
437 .read()
438 .map_err(|e| anyhow::anyhow!("failed to exchange bearer token for session token: {e}"))?;
439
440 let session_json: serde_json::Value = serde_json::from_str(&session_response)
441 .map_err(|_| anyhow::anyhow!("failed to parse session token response from Azure DevOps"))?;
442
443 let session_token = session_json["token"]
444 .as_str()
445 .ok_or_else(|| anyhow::anyhow!("session token response missing 'token' field"))?;
446
447 log::info!("obtained Azure DevOps session token for nuget auth");
448
449 // 3. Build the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS JSON.
450 // This is the same format that NuGetAuthenticate@1 uses in ADO CI.
451 let endpoints: Vec<serde_json::Value> = ado_feeds
452 .iter()
453 .map(|url| {
454 serde_json::json!({
455 "endpoint": url,
456 "username": "AzureDevOps",
457 "password": session_token,
458 })
459 })
460 .collect();
461
462 let feed_json = serde_json::json!({
463 "endpointCredentials": endpoints,
464 })
465 .to_string();
466
467 Ok(Some(feed_json))
468}
469
470/// Move a directory, falling back to recursive copy + delete if rename fails
471/// (e.g. across filesystem boundaries where rename returns EXDEV).
472fn move_dir(src: &Path, dest: &Path) -> anyhow::Result<()> {
473 match fs_err::rename(src, dest) {
474 Ok(()) => Ok(()),
475 Err(e) => {
476 // rename(2) fails with EXDEV (errno 18 on Linux, error 17 on
477 // Windows) when src and dest are on different filesystems.
478 // Fall back to a recursive copy + delete.
479 log::debug!(
480 "rename failed ({}), falling back to copy+delete for {}",
481 e,
482 src.display()
483 );
484 crate::_util::copy_dir_all(src, dest)?;
485 fs_err::remove_dir_all(src)?;
486 Ok(())
487 }
488 }
489}
490
491/// Check whether a feed URL is an Azure DevOps Artifacts feed.
492fn is_azure_devops_feed(url: &str) -> bool {
493 let lower = url.to_lowercase();
494 lower.contains("pkgs.dev.azure.com") || lower.contains(".pkgs.visualstudio.com")
495}