winbrew_app\catalog/
select.rs

1//! Host-aware installer selection policy for catalog packages.
2//!
3//! Catalog packages can publish multiple installers for different architectures,
4//! platform families, and install scopes. This module centralizes the ranking
5//! rules so callers do not need to replicate host-profile fallback logic in
6//! command code.
7//!
8//! The selection order is intentionally simple and predictable:
9//!
10//! - ignore installers whose platform metadata does not match the host family
11//! - prefer installers whose scope matches the current elevation state
12//! - prefer an installer that exactly matches the host architecture
13//! - fall back to `Architecture::Any` when no exact match exists
14//! - fall back to the first scope-compatible installer if nothing else matches
15
16use crate::models::domains::catalog::CatalogInstaller;
17use crate::models::domains::install::Architecture;
18use crate::windows::host::HostProfile;
19use thiserror::Error;
20use tracing::debug;
21
22/// Selection inputs derived from the current runtime host state.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) struct SelectionContext {
25    /// Host family and native architecture snapshot.
26    pub host_profile: HostProfile,
27    /// `true` when the current process is running elevated.
28    pub is_elevated: bool,
29}
30
31impl SelectionContext {
32    /// Build a new selection context from the current host state.
33    pub(crate) fn new(host_profile: HostProfile, is_elevated: bool) -> Self {
34        Self {
35            host_profile,
36            is_elevated,
37        }
38    }
39}
40
41/// Raised when catalog installers cannot be matched to the current host.
42#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
43pub(crate) enum InstallerSelectionError {
44    /// The catalog package has no installers at all.
45    #[error("catalog package has no installers")]
46    NoInstallers,
47    /// The package has installers, but none match this host's platform family.
48    #[error("no installer matches this host ({host})")]
49    PlatformMismatch { host: HostProfile },
50    /// The package has installers, but none match this host's install scope.
51    #[error("no installer matches this host's install scope ({host})")]
52    ScopeMismatch { host: HostProfile },
53}
54
55/// Select the best installer for the current host.
56///
57/// The helper returns a structured error when the catalog package has no
58/// installers or when none of the installers are compatible with this host.
59pub(crate) fn select_installer(
60    installers: &[CatalogInstaller],
61    selection_context: SelectionContext,
62) -> Result<CatalogInstaller, InstallerSelectionError> {
63    if installers.is_empty() {
64        return Err(InstallerSelectionError::NoInstallers);
65    }
66
67    let compatible_installers: Vec<&CatalogInstaller> = installers
68        .iter()
69        .filter(|installer| {
70            platform_matches_host(
71                installer.platform.as_deref(),
72                selection_context.host_profile,
73            )
74        })
75        .collect();
76
77    if compatible_installers.is_empty() {
78        return Err(InstallerSelectionError::PlatformMismatch {
79            host: selection_context.host_profile,
80        });
81    }
82
83    let scope_compatible_installers =
84        scope_compatible_installers(&compatible_installers, selection_context)?;
85
86    debug!(
87        installer_count = installers.len(),
88        compatible_count = compatible_installers.len(),
89        scope_compatible_count = scope_compatible_installers.len(),
90        host = %selection_context.host_profile,
91        elevated = selection_context.is_elevated,
92        "selecting best installer"
93    );
94
95    let selected = scope_compatible_installers
96        .iter()
97        .find(|installer| installer.arch == selection_context.host_profile.architecture)
98        .copied()
99        .or_else(|| {
100            scope_compatible_installers
101                .iter()
102                .find(|installer| installer.arch == Architecture::Any)
103                .copied()
104        })
105        .or_else(|| scope_compatible_installers.first().copied())
106        .expect("compatible installers should not be empty");
107
108    Ok(selected.clone())
109}
110
111fn scope_compatible_installers<'a>(
112    installers: &'a [&'a CatalogInstaller],
113    selection_context: SelectionContext,
114) -> Result<Vec<&'a CatalogInstaller>, InstallerSelectionError> {
115    let scope_compatible_installers: Vec<&CatalogInstaller> = installers
116        .iter()
117        .copied()
118        .filter(|installer| {
119            scope_matches_host(installer.scope.as_deref(), selection_context.is_elevated)
120        })
121        .collect();
122
123    if scope_compatible_installers.is_empty() {
124        return Err(InstallerSelectionError::ScopeMismatch {
125            host: selection_context.host_profile,
126        });
127    }
128
129    if selection_context.is_elevated {
130        let machine_installers: Vec<&CatalogInstaller> = scope_compatible_installers
131            .iter()
132            .copied()
133            .filter(|installer| {
134                installer_scope_kind(installer.scope.as_deref()) == InstallerScopeKind::Machine
135            })
136            .collect();
137
138        if machine_installers.is_empty() {
139            Ok(scope_compatible_installers)
140        } else {
141            Ok(machine_installers)
142        }
143    } else {
144        Ok(scope_compatible_installers)
145    }
146}
147
148fn platform_matches_host(platform: Option<&str>, host_profile: HostProfile) -> bool {
149    let Some(platform) = platform else {
150        return true;
151    };
152
153    let Ok(platform_values) = serde_json::from_str::<Vec<String>>(platform) else {
154        return false;
155    };
156
157    let accepted_platforms = host_profile.platform_tags();
158
159    platform_values
160        .iter()
161        .map(|value| value.trim())
162        .filter(|value| !value.is_empty())
163        .any(|value| {
164            accepted_platforms
165                .iter()
166                .any(|accepted| value.eq_ignore_ascii_case(accepted))
167        })
168}
169
170fn scope_matches_host(scope: Option<&str>, is_elevated: bool) -> bool {
171    !matches!(installer_scope_kind(scope), InstallerScopeKind::Machine) || is_elevated
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum InstallerScopeKind {
176    User,
177    Machine,
178    Generic,
179}
180
181fn installer_scope_kind(scope: Option<&str>) -> InstallerScopeKind {
182    let Some(scope) = scope.map(str::trim).filter(|value| !value.is_empty()) else {
183        return InstallerScopeKind::Generic;
184    };
185
186    if scope.eq_ignore_ascii_case("machine") {
187        InstallerScopeKind::Machine
188    } else if scope.eq_ignore_ascii_case("user") {
189        InstallerScopeKind::User
190    } else {
191        InstallerScopeKind::Generic
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use anyhow::Result;
199    use winbrew_testing::{CatalogInstallerBuilderExt as _, catalog_installer};
200
201    fn sample_installer(
202        arch: Architecture,
203        kind: winbrew_models::domains::install::InstallerType,
204        platform: Option<&str>,
205        scope: Option<&str>,
206    ) -> CatalogInstaller {
207        let mut installer = catalog_installer("Contoso.App".into(), "https://example.test/app.exe")
208            .with_arch(arch)
209            .with_kind(kind);
210
211        installer.platform = platform.map(str::to_string);
212        installer.scope = scope.map(str::to_string);
213        installer
214    }
215
216    fn selection_context(
217        is_server: bool,
218        architecture: Architecture,
219        is_elevated: bool,
220    ) -> SelectionContext {
221        SelectionContext::new(
222            HostProfile {
223                is_server,
224                architecture,
225            },
226            is_elevated,
227        )
228    }
229
230    fn normal_host(architecture: Architecture, is_elevated: bool) -> SelectionContext {
231        selection_context(false, architecture, is_elevated)
232    }
233
234    fn server_host(architecture: Architecture, is_elevated: bool) -> SelectionContext {
235        selection_context(true, architecture, is_elevated)
236    }
237
238    #[test]
239    fn select_installer_prefers_matching_arch_for_normal_hosts() -> Result<()> {
240        let installers = vec![
241            sample_installer(
242                Architecture::Any,
243                winbrew_models::domains::install::InstallerType::Portable,
244                Some("[\"Windows.Desktop\"]"),
245                None,
246            ),
247            sample_installer(
248                Architecture::X64,
249                winbrew_models::domains::install::InstallerType::Msix,
250                Some("[\"Windows.Desktop\"]"),
251                None,
252            ),
253            sample_installer(
254                Architecture::X86,
255                winbrew_models::domains::install::InstallerType::Zip,
256                Some("[\"Windows.Desktop\"]"),
257                None,
258            ),
259        ];
260
261        let selected = select_installer(&installers, normal_host(Architecture::X64, false))
262            .expect("installer should exist");
263
264        assert_eq!(selected.arch, Architecture::X64);
265        assert_eq!(
266            selected.kind,
267            winbrew_models::domains::install::InstallerType::Msix
268        );
269
270        Ok(())
271    }
272
273    #[test]
274    fn select_installer_prefers_server_platform_on_server_hosts() -> Result<()> {
275        let installers = vec![
276            sample_installer(
277                Architecture::X64,
278                winbrew_models::domains::install::InstallerType::Exe,
279                Some("[\"Windows.Desktop\"]"),
280                None,
281            ),
282            sample_installer(
283                Architecture::X64,
284                winbrew_models::domains::install::InstallerType::Portable,
285                Some("[\"Windows.Server\"]"),
286                None,
287            ),
288            sample_installer(
289                Architecture::Any,
290                winbrew_models::domains::install::InstallerType::Zip,
291                Some("[\"Windows.Server\"]"),
292                None,
293            ),
294        ];
295
296        let selected = select_installer(&installers, server_host(Architecture::X64, false))
297            .expect("installer should exist");
298
299        assert_eq!(selected.arch, Architecture::X64);
300        assert_eq!(
301            selected.kind,
302            winbrew_models::domains::install::InstallerType::Portable
303        );
304
305        Ok(())
306    }
307
308    #[test]
309    fn select_installer_accepts_windows_universal_on_normal_hosts() -> Result<()> {
310        let installers = vec![sample_installer(
311            Architecture::X64,
312            winbrew_models::domains::install::InstallerType::Msix,
313            Some("[\"WINDOWS.UNIVERSAL\"]"),
314            None,
315        )];
316
317        let selected = select_installer(&installers, normal_host(Architecture::X64, false))
318            .expect("installer should exist");
319
320        assert_eq!(
321            selected.kind,
322            winbrew_models::domains::install::InstallerType::Msix
323        );
324        assert_eq!(selected.arch, Architecture::X64);
325
326        Ok(())
327    }
328
329    #[test]
330    fn select_installer_prefers_machine_scope_when_elevated() -> Result<()> {
331        let installers = vec![
332            sample_installer(
333                Architecture::X64,
334                winbrew_models::domains::install::InstallerType::Portable,
335                Some("[\"Windows.Universal\"]"),
336                Some("user"),
337            ),
338            sample_installer(
339                Architecture::X86,
340                winbrew_models::domains::install::InstallerType::Msix,
341                Some("[\"Windows.Universal\"]"),
342                Some("machine"),
343            ),
344        ];
345
346        let selected = select_installer(&installers, normal_host(Architecture::X64, true))
347            .expect("installer should exist");
348
349        assert_eq!(selected.scope.as_deref(), Some("machine"));
350        assert_eq!(selected.arch, Architecture::X86);
351
352        Ok(())
353    }
354
355    #[test]
356    fn select_installer_allows_generic_installers_when_platform_is_missing() -> Result<()> {
357        let installers = vec![
358            sample_installer(
359                Architecture::Any,
360                winbrew_models::domains::install::InstallerType::Portable,
361                None,
362                None,
363            ),
364            sample_installer(
365                Architecture::X64,
366                winbrew_models::domains::install::InstallerType::Msix,
367                Some("[\"Windows.Server\"]"),
368                None,
369            ),
370        ];
371
372        let selected = select_installer(&installers, normal_host(Architecture::Arm64, false))
373            .expect("installer should exist");
374
375        assert_eq!(selected.arch, Architecture::Any);
376        assert_eq!(
377            selected.kind,
378            winbrew_models::domains::install::InstallerType::Portable
379        );
380
381        Ok(())
382    }
383
384    #[test]
385    fn select_installer_returns_no_compatible_installer_when_platforms_do_not_match() {
386        let installers = vec![sample_installer(
387            Architecture::X64,
388            winbrew_models::domains::install::InstallerType::Exe,
389            Some("[\"Windows.Server\"]"),
390            None,
391        )];
392
393        let err = select_installer(&installers, normal_host(Architecture::X64, false))
394            .expect_err("installer should not match");
395
396        assert_eq!(
397            err,
398            InstallerSelectionError::PlatformMismatch {
399                host: HostProfile {
400                    is_server: false,
401                    architecture: Architecture::X64,
402                },
403            }
404        );
405    }
406
407    #[test]
408    fn select_installer_returns_scope_error_for_machine_only_installers_when_not_elevated() {
409        let installers = vec![sample_installer(
410            Architecture::X64,
411            winbrew_models::domains::install::InstallerType::Exe,
412            Some("[\"Windows.Universal\"]"),
413            Some("machine"),
414        )];
415
416        let err = select_installer(&installers, normal_host(Architecture::X64, false))
417            .expect_err("installer should not match");
418
419        assert_eq!(
420            err,
421            InstallerSelectionError::ScopeMismatch {
422                host: HostProfile {
423                    is_server: false,
424                    architecture: Architecture::X64,
425                },
426            }
427        );
428    }
429
430    #[test]
431    fn select_installer_returns_no_installers_when_list_is_empty() {
432        let err = select_installer(&[], normal_host(Architecture::X64, false))
433            .expect_err("selection should fail");
434
435        assert!(matches!(err, InstallerSelectionError::NoInstallers));
436    }
437}