Skip to main content

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}