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}