Skip to main content

fission_command_core/
windows_native.rs

1use crate::{
2    native_cargo::{cargo_target_directory, expand_cargo_target_directory},
3    FissionProject, NativeVariant,
4};
5use anyhow::{bail, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11
12#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13pub struct NativeWindowsModuleConfig {
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub cargo_manifest_path: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub cargo_package: Option<String>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub features: Vec<String>,
20    #[serde(default, skip_serializing_if = "is_false")]
21    pub no_default_features: bool,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub nuget_packages_config: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub nuget_packages_directory: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub msbuild_project: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub platform: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub build_target: Option<String>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub test_binaries: Vec<String>,
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub products: Vec<NativeWindowsProductConfig>,
36}
37
38impl NativeWindowsModuleConfig {
39    pub fn is_empty(&self) -> bool {
40        self.cargo_manifest_path.is_none()
41            && self.cargo_package.is_none()
42            && self.features.is_empty()
43            && !self.no_default_features
44            && self.nuget_packages_config.is_none()
45            && self.nuget_packages_directory.is_none()
46            && self.msbuild_project.is_none()
47            && self.platform.is_none()
48            && self.build_target.is_none()
49            && self.test_binaries.is_empty()
50            && self.products.is_empty()
51    }
52}
53
54fn is_false(value: &bool) -> bool {
55    !*value
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum NativeWindowsProductKind {
61    Runtime,
62    DriverPackage,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct NativeWindowsProductConfig {
67    pub name: String,
68    pub path: String,
69    pub kind: NativeWindowsProductKind,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub destination: Option<String>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
75pub struct BuiltWindowsNativeProduct {
76    pub module: String,
77    pub name: String,
78    pub kind: NativeWindowsProductKind,
79    pub source: PathBuf,
80    pub destination: PathBuf,
81}
82
83pub fn build_windows_native_modules(
84    project_dir: &Path,
85    project: &FissionProject,
86    variant: Option<&NativeVariant>,
87    release: bool,
88) -> Result<Vec<BuiltWindowsNativeProduct>> {
89    let project_dir = canonical_project_dir(project_dir)?;
90    let configuration = if release { "Release" } else { "Debug" };
91    let mut products = Vec::new();
92
93    for module in project.native_modules_for_variant(variant) {
94        if module.windows.is_empty() {
95            continue;
96        }
97        let platform = optional_value(module.windows.platform.as_deref()).unwrap_or("x64");
98        let target_directory = match windows_build_system(&module.name, &module.windows)? {
99            WindowsBuildSystem::Cargo => Some(run_cargo_module_command(
100                &project_dir,
101                module.path.as_deref(),
102                &module.name,
103                &module.windows,
104                "build",
105                release,
106            )?),
107            WindowsBuildSystem::MsBuild => {
108                let native_tool_paths =
109                    restore_windows_native_packages(&project_dir, &module.name, &module.windows)?;
110                let build_project = required_config_path(
111                    &project_dir,
112                    module.windows.msbuild_project.as_deref(),
113                    &module.name,
114                )?;
115                let target =
116                    optional_value(module.windows.build_target.as_deref()).unwrap_or("Build");
117                let mut command = Command::new("msbuild");
118                command
119                    .arg(windows_external_tool_path(&build_project))
120                    .arg("/m")
121                    .arg(format!("/t:{target}"))
122                    .arg(format!("/p:Configuration={configuration}"))
123                    .arg(format!("/p:Platform={platform}"));
124                prepend_windows_native_tool_paths(&mut command, &native_tool_paths)?;
125                run_status(
126                    &mut command,
127                    &format!("Windows native module `{}`", module.name),
128                )?;
129                None
130            }
131        };
132
133        for product in &module.windows.products {
134            products.push(resolve_product(
135                &project_dir,
136                &module.name,
137                product,
138                configuration,
139                platform,
140                target_directory.as_deref(),
141            )?);
142        }
143    }
144
145    Ok(products)
146}
147
148fn restore_windows_native_packages(
149    project_dir: &Path,
150    module_name: &str,
151    config: &NativeWindowsModuleConfig,
152) -> Result<Vec<PathBuf>> {
153    let Some(packages_config) = optional_value(config.nuget_packages_config.as_deref()) else {
154        if config.nuget_packages_directory.is_some() {
155            bail!(
156                "Windows native module `{module_name}` sets `nuget_packages_directory` without `nuget_packages_config`"
157            );
158        }
159        return Ok(Vec::new());
160    };
161    let packages_config = resolve_project_path(project_dir, packages_config);
162    if !packages_config.is_file() {
163        bail!(
164            "Windows native module `{module_name}` NuGet packages config does not exist: {}",
165            packages_config.display()
166        );
167    }
168    let packages_directory = optional_value(config.nuget_packages_directory.as_deref())
169        .map(|path| resolve_project_path(project_dir, path))
170        .unwrap_or_else(|| {
171            packages_config
172                .parent()
173                .unwrap_or(project_dir)
174                .join("packages")
175        });
176    let mut command = Command::new("nuget");
177    command
178        .arg("restore")
179        .arg(windows_external_tool_path(&packages_config))
180        .arg("-PackagesDirectory")
181        .arg(windows_external_tool_path(&packages_directory))
182        .arg("-NonInteractive");
183    run_status(
184        &mut command,
185        &format!("Windows native module `{module_name}` NuGet restore"),
186    )?;
187
188    discover_windows_native_tool_paths(&packages_directory, env::consts::ARCH)
189}
190
191fn discover_windows_native_tool_paths(
192    packages_directory: &Path,
193    host_architecture: &str,
194) -> Result<Vec<PathBuf>> {
195    let host_architecture = match host_architecture {
196        "x86_64" => "x64",
197        "aarch64" => "ARM64",
198        _ => return Ok(Vec::new()),
199    };
200    let mut paths = Vec::new();
201
202    for package in read_directories(packages_directory)? {
203        let bin_root = package.join("c").join("bin");
204        if !bin_root.is_dir() {
205            continue;
206        }
207        for version in read_directories(&bin_root)? {
208            let candidate = version.join(host_architecture);
209            if candidate.is_dir() {
210                paths.push(candidate);
211            }
212        }
213    }
214
215    paths.sort();
216    paths.dedup();
217    Ok(paths)
218}
219
220fn read_directories(root: &Path) -> Result<Vec<PathBuf>> {
221    let mut paths = Vec::new();
222    for entry in fs::read_dir(root).with_context(|| {
223        format!(
224            "failed to inspect restored NuGet directory {}",
225            root.display()
226        )
227    })? {
228        let entry = entry?;
229        if entry.file_type()?.is_dir() {
230            paths.push(entry.path());
231        }
232    }
233    paths.sort();
234    Ok(paths)
235}
236
237fn prepend_windows_native_tool_paths(command: &mut Command, paths: &[PathBuf]) -> Result<()> {
238    if paths.is_empty() {
239        return Ok(());
240    }
241    let mut combined = paths
242        .iter()
243        .map(|path| windows_external_tool_path(path))
244        .collect::<Vec<_>>();
245    if let Some(current) = env::var_os("PATH") {
246        combined.extend(env::split_paths(&current));
247    }
248    let joined = env::join_paths(combined)
249        .context("failed to construct PATH for Windows native module tools")?;
250    command.env("PATH", joined);
251    Ok(())
252}
253
254pub fn test_windows_native_modules(
255    project_dir: &Path,
256    project: &FissionProject,
257    variant: Option<&NativeVariant>,
258) -> Result<()> {
259    let products = build_windows_native_modules(project_dir, project, variant, false)?;
260    drop(products);
261    let project_dir = canonical_project_dir(project_dir)?;
262
263    for module in project.native_modules_for_variant(variant) {
264        if module.windows.is_empty() {
265            continue;
266        }
267        if windows_build_system(&module.name, &module.windows)? == WindowsBuildSystem::Cargo {
268            run_cargo_module_command(
269                &project_dir,
270                module.path.as_deref(),
271                &module.name,
272                &module.windows,
273                "test",
274                false,
275            )?;
276            continue;
277        }
278        let platform = optional_value(module.windows.platform.as_deref()).unwrap_or("x64");
279        for test_binary in &module.windows.test_binaries {
280            let test_binary = expand_path(test_binary, "Debug", platform, None, &module.name)?;
281            let test_binary = resolve_project_path(&project_dir, &test_binary);
282            if !test_binary.is_file() {
283                bail!(
284                    "Windows native test binary for module `{}` does not exist: {}",
285                    module.name,
286                    test_binary.display()
287                );
288            }
289            let mut command = Command::new("vstest.console.exe");
290            command.arg(windows_external_tool_path(&test_binary));
291            run_status(
292                &mut command,
293                &format!("Windows native test binary `{}`", test_binary.display()),
294            )?;
295        }
296    }
297
298    Ok(())
299}
300
301#[derive(Clone, Copy, Debug, PartialEq, Eq)]
302enum WindowsBuildSystem {
303    Cargo,
304    MsBuild,
305}
306
307fn windows_build_system(
308    module_name: &str,
309    config: &NativeWindowsModuleConfig,
310) -> Result<WindowsBuildSystem> {
311    match (
312        optional_value(config.cargo_package.as_deref()),
313        optional_value(config.msbuild_project.as_deref()),
314    ) {
315        (Some(_), None) => {
316            if config.nuget_packages_config.is_some()
317                || config.nuget_packages_directory.is_some()
318                || config.build_target.is_some()
319                || !config.test_binaries.is_empty()
320            {
321                bail!(
322                    "Windows native Cargo module `{module_name}` cannot set MSBuild, NuGet, or test-binary options"
323                );
324            }
325            Ok(WindowsBuildSystem::Cargo)
326        }
327        (None, Some(_)) => {
328            if config.cargo_manifest_path.is_some()
329                || !config.features.is_empty()
330                || config.no_default_features
331            {
332                bail!(
333                    "Windows native MSBuild module `{module_name}` cannot set Cargo options"
334                );
335            }
336            Ok(WindowsBuildSystem::MsBuild)
337        }
338        (Some(_), Some(_)) => bail!(
339            "Windows native module `{module_name}` must select either `cargo_package` or `msbuild_project`, not both"
340        ),
341        (None, None) => bail!(
342            "Windows native module `{module_name}` requires `cargo_package` or `msbuild_project`"
343        ),
344    }
345}
346
347fn run_cargo_module_command(
348    project_dir: &Path,
349    module_path: Option<&str>,
350    module_name: &str,
351    config: &NativeWindowsModuleConfig,
352    cargo_command: &str,
353    release: bool,
354) -> Result<PathBuf> {
355    let package = optional_value(config.cargo_package.as_deref()).with_context(|| {
356        format!("Windows native module `{module_name}` requires `cargo_package`")
357    })?;
358    let manifest = resolve_cargo_manifest_path(
359        project_dir,
360        module_path,
361        config.cargo_manifest_path.as_deref(),
362        module_name,
363    )?;
364    let mut command = Command::new("cargo");
365    command
366        .arg(cargo_command)
367        .arg("--manifest-path")
368        .arg(&manifest)
369        .arg("--package")
370        .arg(package)
371        .current_dir(project_dir);
372    if release && cargo_command == "build" {
373        command.arg("--release");
374    }
375    if config.no_default_features {
376        command.arg("--no-default-features");
377    }
378    if !config.features.is_empty() {
379        let features = config
380            .features
381            .iter()
382            .map(|feature| required_value(feature, "Windows native Cargo feature"))
383            .collect::<Result<Vec<_>>>()?
384            .join(",");
385        command.arg("--features").arg(features);
386    }
387    run_status(
388        &mut command,
389        &format!("Windows native module `{module_name}` Cargo {cargo_command}"),
390    )?;
391    cargo_target_directory(project_dir, &manifest, module_name, "Windows")
392}
393
394fn resolve_cargo_manifest_path(
395    project_dir: &Path,
396    module_path: Option<&str>,
397    configured: Option<&str>,
398    module_name: &str,
399) -> Result<PathBuf> {
400    let manifest = if let Some(configured) = optional_value(configured) {
401        resolve_project_path(project_dir, configured)
402    } else if let Some(module_path) = optional_value(module_path) {
403        resolve_project_path(project_dir, module_path).join("Cargo.toml")
404    } else {
405        project_dir.join("Cargo.toml")
406    };
407    if !manifest.is_file() {
408        bail!(
409            "Windows native Cargo manifest for module `{module_name}` does not exist: {}",
410            manifest.display()
411        );
412    }
413    Ok(manifest)
414}
415
416pub fn stage_windows_runtime_products(
417    destination_root: &Path,
418    products: &[BuiltWindowsNativeProduct],
419) -> Result<()> {
420    for product in products {
421        if product.kind != NativeWindowsProductKind::Runtime {
422            continue;
423        }
424        let destination = destination_root.join(&product.destination);
425        if destination.exists() {
426            bail!(
427                "Windows native runtime product `{}` would overwrite {}",
428                product.name,
429                destination.display()
430            );
431        }
432        copy_product(&product.source, &destination)?;
433    }
434    Ok(())
435}
436
437fn required_config_path(
438    project_dir: &Path,
439    configured: Option<&str>,
440    module_name: &str,
441) -> Result<PathBuf> {
442    let configured = optional_value(configured).with_context(|| {
443        format!("Windows native module `{module_name}` requires `msbuild_project`")
444    })?;
445    let path = resolve_project_path(project_dir, configured);
446    if !path.is_file() {
447        bail!(
448            "Windows native module `{module_name}` MSBuild project does not exist: {}",
449            path.display()
450        );
451    }
452    Ok(path)
453}
454
455fn resolve_product(
456    project_dir: &Path,
457    module_name: &str,
458    product: &NativeWindowsProductConfig,
459    configuration: &str,
460    platform: &str,
461    cargo_target_directory: Option<&Path>,
462) -> Result<BuiltWindowsNativeProduct> {
463    let name = required_value(&product.name, "Windows native product name")?;
464    let path = required_value(&product.path, "Windows native product path")?;
465    let expanded = expand_path(
466        path,
467        configuration,
468        platform,
469        cargo_target_directory,
470        module_name,
471    )?;
472    let source = resolve_project_path(project_dir, &expanded);
473    if !source.exists() {
474        bail!(
475            "Windows native product `{name}` from module `{module_name}` does not exist: {}",
476            source.display()
477        );
478    }
479    let default_destination = source
480        .file_name()
481        .map(PathBuf::from)
482        .context("Windows native product source has no file name")?;
483    let destination = product
484        .destination
485        .as_deref()
486        .map(str::trim)
487        .filter(|value| !value.is_empty())
488        .map(PathBuf::from)
489        .unwrap_or(default_destination);
490    validate_relative_destination(&destination)?;
491
492    Ok(BuiltWindowsNativeProduct {
493        module: module_name.to_string(),
494        name: name.to_string(),
495        kind: product.kind,
496        source,
497        destination,
498    })
499}
500
501fn validate_relative_destination(destination: &Path) -> Result<()> {
502    if destination.as_os_str().is_empty() || destination.is_absolute() {
503        bail!("Windows native product destination must be a non-empty relative path");
504    }
505    if destination.components().any(|component| {
506        matches!(
507            component,
508            Component::ParentDir | Component::RootDir | Component::Prefix(_)
509        )
510    }) {
511        bail!(
512            "Windows native product destination cannot escape the application or installer root: {}",
513            destination.display()
514        );
515    }
516    Ok(())
517}
518
519fn expand_path(
520    value: &str,
521    configuration: &str,
522    platform: &str,
523    cargo_target_directory: Option<&Path>,
524    module_name: &str,
525) -> Result<String> {
526    let value = value
527        .replace("{configuration}", configuration)
528        .replace("{profile}", &configuration.to_ascii_lowercase())
529        .replace("{platform}", platform);
530    expand_cargo_target_directory(&value, cargo_target_directory, module_name, "Windows")
531}
532
533fn copy_product(source: &Path, destination: &Path) -> Result<()> {
534    if source.is_dir() {
535        fs::create_dir_all(destination)?;
536        for entry in fs::read_dir(source)? {
537            let entry = entry?;
538            copy_product(&entry.path(), &destination.join(entry.file_name()))?;
539        }
540        return Ok(());
541    }
542    let parent = destination
543        .parent()
544        .context("Windows native product destination has no parent")?;
545    fs::create_dir_all(parent)?;
546    fs::copy(source, destination).with_context(|| {
547        format!(
548            "failed to copy Windows native product {} to {}",
549            source.display(),
550            destination.display()
551        )
552    })?;
553    Ok(())
554}
555
556fn resolve_project_path(project_dir: &Path, value: &str) -> PathBuf {
557    let path = Path::new(value);
558    if path.is_absolute() {
559        path.to_path_buf()
560    } else {
561        project_dir.join(path)
562    }
563}
564
565fn canonical_project_dir(project_dir: &Path) -> Result<PathBuf> {
566    fs::canonicalize(project_dir).with_context(|| {
567        format!(
568            "failed to resolve project directory {}",
569            project_dir.display()
570        )
571    })
572}
573
574fn windows_external_tool_path(path: &Path) -> PathBuf {
575    #[cfg(windows)]
576    {
577        use std::ffi::OsString;
578        use std::os::windows::ffi::{OsStrExt, OsStringExt};
579
580        let encoded = path.as_os_str().encode_wide().collect::<Vec<_>>();
581        return normalize_windows_verbatim_units(&encoded)
582            .map(|units| PathBuf::from(OsString::from_wide(&units)))
583            .unwrap_or_else(|| path.to_path_buf());
584    }
585
586    #[cfg(not(windows))]
587    path.to_path_buf()
588}
589
590#[cfg(any(windows, test))]
591fn normalize_windows_verbatim_units(path: &[u16]) -> Option<Vec<u16>> {
592    const VERBATIM_PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16];
593    const VERBATIM_UNC_PREFIX: &[u16] = &[
594        b'\\' as u16,
595        b'\\' as u16,
596        b'?' as u16,
597        b'\\' as u16,
598        b'U' as u16,
599        b'N' as u16,
600        b'C' as u16,
601        b'\\' as u16,
602    ];
603
604    if let Some(remainder) = path.strip_prefix(VERBATIM_UNC_PREFIX) {
605        let mut normalized = vec![b'\\' as u16, b'\\' as u16];
606        normalized.extend_from_slice(remainder);
607        return Some(normalized);
608    }
609    path.strip_prefix(VERBATIM_PREFIX).map(Vec::from)
610}
611
612fn required_value<'a>(value: &'a str, label: &str) -> Result<&'a str> {
613    let value = value.trim();
614    if value.is_empty() {
615        bail!("{label} cannot be empty");
616    }
617    Ok(value)
618}
619
620fn optional_value(value: Option<&str>) -> Option<&str> {
621    value.map(str::trim).filter(|value| !value.is_empty())
622}
623
624fn run_status(command: &mut Command, label: &str) -> Result<()> {
625    let status = command
626        .status()
627        .with_context(|| format!("failed to run {label}"))?;
628    if !status.success() {
629        bail!("{label} failed with {status}");
630    }
631    Ok(())
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::FissionProject;
638
639    #[test]
640    fn parses_windows_native_products() {
641        let project: FissionProject = toml::from_str(
642            r#"
643targets = ["windows"]
644
645[app]
646name = "demo"
647app_id = "com.example.demo"
648
649[[native.modules]]
650name = "demo-native"
651
652[native.modules.windows]
653nuget_packages_config = "platforms/windows/native/packages.config"
654nuget_packages_directory = "platforms/windows/native/packages"
655msbuild_project = "platforms/windows/native/Demo.sln"
656platform = "x64"
657test_binaries = ["platforms/windows/native/{platform}/{configuration}/DemoTests.exe"]
658
659[[native.modules.windows.products]]
660name = "provider"
661path = "platforms/windows/native/{platform}/{configuration}/Provider.dll"
662kind = "runtime"
663destination = "native/Provider.dll"
664
665[[native.modules.windows.products]]
666name = "minifilter"
667path = "platforms/windows/native/{platform}/{configuration}/DriverPackage"
668kind = "driver-package"
669"#,
670        )
671        .unwrap();
672
673        let module = &project.native.modules[0].windows;
674        assert_eq!(module.platform.as_deref(), Some("x64"));
675        assert_eq!(
676            module.nuget_packages_config.as_deref(),
677            Some("platforms/windows/native/packages.config")
678        );
679        assert_eq!(module.products.len(), 2);
680        assert_eq!(
681            module.products[1].kind,
682            NativeWindowsProductKind::DriverPackage
683        );
684    }
685
686    #[test]
687    fn parses_windows_cargo_native_products() {
688        let project: FissionProject = toml::from_str(
689            r#"
690targets = ["windows"]
691
692[app]
693name = "demo"
694app_id = "com.example.demo"
695
696[[native.modules]]
697name = "demo-helper"
698path = "../demo-helper"
699
700[native.modules.windows]
701cargo_package = "demo-helper"
702features = ["installer"]
703no_default_features = true
704
705[[native.modules.windows.products]]
706name = "helper"
707path = "../../target/{profile}/demo-helper.exe"
708kind = "runtime"
709destination = "tools/demo-helper.exe"
710"#,
711        )
712        .unwrap();
713
714        let module = &project.native.modules[0].windows;
715        assert_eq!(module.cargo_package.as_deref(), Some("demo-helper"));
716        assert_eq!(module.features, ["installer"]);
717        assert!(module.no_default_features);
718        assert_eq!(
719            windows_build_system("demo-helper", module).unwrap(),
720            WindowsBuildSystem::Cargo
721        );
722    }
723
724    #[test]
725    fn rejects_ambiguous_windows_native_build_systems() {
726        let config = NativeWindowsModuleConfig {
727            cargo_package: Some("demo".into()),
728            msbuild_project: Some("Demo.sln".into()),
729            ..Default::default()
730        };
731        let error = windows_build_system("demo", &config).unwrap_err();
732        assert!(error
733            .to_string()
734            .contains("either `cargo_package` or `msbuild_project`"));
735    }
736
737    #[test]
738    fn expands_configuration_and_platform_tokens() {
739        assert_eq!(
740            expand_path(
741                "{cargo_target_dir}/{platform}/{configuration}/{profile}",
742                "Release",
743                "ARM64",
744                Some(Path::new("C:/shared/cargo")),
745                "demo-native",
746            )
747            .unwrap(),
748            "C:/shared/cargo/ARM64/Release/release"
749        );
750    }
751
752    #[test]
753    fn rejects_destination_traversal() {
754        let error = validate_relative_destination(Path::new("../driver.sys")).unwrap_err();
755        assert!(error.to_string().contains("cannot escape"));
756    }
757
758    #[test]
759    fn stages_runtime_products_without_driver_packages() {
760        let root = unique_dir("windows-native-stage");
761        let source = root.join("source");
762        let destination = root.join("destination");
763        fs::create_dir_all(&source).unwrap();
764        fs::write(source.join("provider.dll"), b"runtime").unwrap();
765        fs::write(source.join("driver.sys"), b"driver").unwrap();
766        let products = vec![
767            BuiltWindowsNativeProduct {
768                module: "demo".into(),
769                name: "provider".into(),
770                kind: NativeWindowsProductKind::Runtime,
771                source: source.join("provider.dll"),
772                destination: PathBuf::from("native/provider.dll"),
773            },
774            BuiltWindowsNativeProduct {
775                module: "demo".into(),
776                name: "driver".into(),
777                kind: NativeWindowsProductKind::DriverPackage,
778                source: source.join("driver.sys"),
779                destination: PathBuf::from("driver/driver.sys"),
780            },
781        ];
782
783        stage_windows_runtime_products(&destination, &products).unwrap();
784
785        assert_eq!(
786            fs::read(destination.join("native/provider.dll")).unwrap(),
787            b"runtime"
788        );
789        assert!(!destination.join("driver/driver.sys").exists());
790    }
791
792    #[test]
793    fn discovers_restored_wdk_tools_for_host_architecture() {
794        let root = unique_dir("windows-native-tools");
795        let x64 = root
796            .join("Microsoft.Windows.WDK.x64.10.0.1")
797            .join("c/bin/10.0.1/x64");
798        let arm64 = root
799            .join("Microsoft.Windows.WDK.x64.10.0.1")
800            .join("c/bin/10.0.1/ARM64");
801        fs::create_dir_all(&x64).unwrap();
802        fs::create_dir_all(&arm64).unwrap();
803        fs::write(x64.join("stampinf.exe"), b"fixture").unwrap();
804
805        let paths = discover_windows_native_tool_paths(&root, "x86_64").unwrap();
806
807        assert_eq!(paths, vec![x64]);
808    }
809
810    #[test]
811    fn ignores_restored_packages_without_native_host_tools() {
812        let root = unique_dir("windows-native-no-tools");
813        fs::create_dir_all(root.join("Example.Package.1.0.0/lib/net8.0")).unwrap();
814
815        let paths = discover_windows_native_tool_paths(&root, "x86_64").unwrap();
816
817        assert!(paths.is_empty());
818    }
819
820    #[test]
821    fn normalizes_verbatim_drive_path_for_windows_tools() {
822        let path = r"\\?\D:\a\demo\packages".encode_utf16().collect::<Vec<_>>();
823        let normalized = normalize_windows_verbatim_units(&path).unwrap();
824
825        assert_eq!(
826            String::from_utf16(normalized.as_slice()).unwrap(),
827            r"D:\a\demo\packages"
828        );
829    }
830
831    #[test]
832    fn normalizes_verbatim_unc_path_for_windows_tools() {
833        let path = r"\\?\UNC\server\share\packages"
834            .encode_utf16()
835            .collect::<Vec<_>>();
836        let normalized = normalize_windows_verbatim_units(&path).unwrap();
837
838        assert_eq!(
839            String::from_utf16(normalized.as_slice()).unwrap(),
840            r"\\server\share\packages"
841        );
842    }
843
844    #[test]
845    fn leaves_non_verbatim_windows_path_unchanged() {
846        let path = r"D:\a\demo\packages".encode_utf16().collect::<Vec<_>>();
847
848        assert!(normalize_windows_verbatim_units(&path).is_none());
849    }
850
851    fn unique_dir(label: &str) -> PathBuf {
852        let path = std::env::temp_dir().join(format!(
853            "fission-{label}-{}-{}",
854            std::process::id(),
855            std::time::SystemTime::now()
856                .duration_since(std::time::UNIX_EPOCH)
857                .unwrap()
858                .as_nanos()
859        ));
860        fs::create_dir_all(&path).unwrap();
861        path
862    }
863}