1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs::{self, OpenOptions};
7use std::io::{Cursor, Read, Write};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::{OsStrExt, OsStringExt};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, OnceLock, RwLock};
14use std::time::{Duration as StdDuration, Instant};
15
16use anyhow::{Context, Result, anyhow, bail};
17use chrono::{DateTime, Duration, Utc};
18use reqwest::StatusCode;
19use reqwest::blocking::{Client, Response};
20use reqwest::header::{
21 ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE,
22 IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE,
23};
24use serde::{Deserialize, Serialize};
25use serde_json::{Map as JsonMap, Value as JsonValue};
26use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
27use sha2::{Digest, Sha256};
28use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
29#[cfg(windows)]
30use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
31#[cfg(windows)]
32use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
33#[cfg(windows)]
34use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
35#[cfg(windows)]
36use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
37#[cfg(windows)]
38use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
39#[cfg(windows)]
40use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
41#[cfg(windows)]
42use winreg::{RegKey, enums::*};
43use zip::ZipArchive;
44
45const DEFAULT_MARKET: &str = "US";
46const DEFAULT_MAX_RESULTS: usize = 50;
47const LIST_LOOKUP_MAX_RESULTS: usize = 500;
48const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
49const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
50const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
51const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
52const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json";
53const APP_ROOT_ENV_VAR: &str = "PINGET_APPROOT";
54const SOURCE_MODE_ENV_VAR: &str = "PINGET_SOURCE_MODE";
55#[cfg(windows)]
56const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
57#[cfg(windows)]
58const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
59const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
60 "Installed package discovery is not supported on this platform; returning no installed packages.";
61const INSTALL_UNSUPPORTED_WARNING: &str =
62 "Installing packages is not supported on this platform; no changes were made.";
63const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
64const REPAIR_REINSTALL_WARNING: &str =
65 "Pinget repair currently re-runs the package install flow for the selected package.";
66const UNINSTALL_UNSUPPORTED_WARNING: &str =
67 "Uninstalling packages is not supported on this platform; no changes were made.";
68#[cfg(windows)]
69const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
70const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
71 "LocalManifestFiles",
72 "BypassCertificatePinningForMicrosoftStore",
73 "InstallerHashOverride",
74 "LocalArchiveMalwareScanOverride",
75 "ProxyCommandLineOptions",
76];
77const REST_SUPPORTED_CONTRACTS: &[&str] = &[
78 "1.12.0", "1.10.0", "1.9.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.1.0", "1.0.0",
79];
80
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
82pub enum SourceKind {
83 #[serde(rename = "PreIndexed", alias = "preIndexed")]
84 PreIndexed,
85 #[serde(rename = "Rest", alias = "rest")]
86 Rest,
87}
88
89impl Display for SourceKind {
90 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
91 match self {
92 SourceKind::PreIndexed => f.write_str("preindexed"),
93 SourceKind::Rest => f.write_str("rest"),
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SourceRecord {
100 pub name: String,
101 pub kind: SourceKind,
102 pub arg: String,
103 pub identifier: String,
104 #[serde(default = "default_source_trust_level")]
105 pub trust_level: String,
106 #[serde(default)]
107 pub explicit: bool,
108 #[serde(default)]
109 pub priority: i32,
110 #[serde(default)]
111 pub last_update: Option<DateTime<Utc>>,
112 #[serde(default)]
113 pub source_version: Option<String>,
114 #[serde(default)]
115 pub etag: Option<String>,
116 #[serde(default)]
117 pub last_modified: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121struct SourceStore {
122 sources: Vec<SourceRecord>,
123}
124
125impl Default for SourceStore {
126 fn default() -> Self {
127 Self {
128 sources: vec![
129 SourceRecord {
130 name: "winget".to_owned(),
131 kind: SourceKind::PreIndexed,
132 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
133 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
134 trust_level: "Trusted".to_owned(),
135 explicit: false,
136 priority: 0,
137 last_update: None,
138 source_version: None,
139 etag: None,
140 last_modified: None,
141 },
142 SourceRecord {
143 name: "msstore".to_owned(),
144 kind: SourceKind::Rest,
145 arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
146 identifier: "StoreEdgeFD".to_owned(),
147 trust_level: "Trusted".to_owned(),
148 explicit: false,
149 priority: 0,
150 last_update: None,
151 source_version: None,
152 etag: None,
153 last_modified: None,
154 },
155 ],
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct RepositoryOptions {
163 pub app_root: PathBuf,
164 pub user_agent: String,
165 pub source_mode: SourceMode,
166 pub pre_indexed_source_auto_update_interval: Option<Duration>,
167 pub install_progress: Option<fn(&InstallProgress)>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum SourceMode {
172 Auto,
173 Private,
174 SystemWingetMirror,
175}
176
177impl SourceMode {
178 fn parse(value: &str) -> Option<Self> {
179 match value.trim().to_ascii_lowercase().as_str() {
180 "auto" => Some(Self::Auto),
181 "private" => Some(Self::Private),
182 "system-winget-mirror" | "systemwingetmirror" => Some(Self::SystemWingetMirror),
183 _ => None,
184 }
185 }
186
187 fn from_env() -> Option<Self> {
188 Self::parse(&std::env::var(SOURCE_MODE_ENV_VAR).ok()?)
189 }
190
191 fn resolve(from_env: Option<Self>, has_app_root_override: bool) -> Self {
192 from_env.unwrap_or(if has_app_root_override {
193 Self::Private
194 } else {
195 Self::Auto
196 })
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201enum EffectiveSourceMode {
202 Private,
203 SystemWingetDirect,
204 SystemWingetMirror,
205}
206
207impl RepositoryOptions {
208 pub fn new(app_root: impl Into<PathBuf>) -> Self {
210 Self {
211 app_root: app_root.into(),
212 user_agent: DEFAULT_USER_AGENT.to_owned(),
213 source_mode: SourceMode::Private,
214 pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
215 install_progress: None,
216 }
217 }
218
219 pub fn for_current_user() -> Result<Self> {
221 let source_mode = SourceMode::resolve(SourceMode::from_env(), std::env::var_os(APP_ROOT_ENV_VAR).is_some());
222 Ok(Self::new(default_app_root()?).with_source_mode(source_mode))
223 }
224
225 #[must_use]
227 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
228 self.user_agent = user_agent.into();
229 self
230 }
231
232 #[must_use]
233 pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self {
234 self.source_mode = source_mode;
235 self
236 }
237
238 #[must_use]
240 pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
241 self.pre_indexed_source_auto_update_interval = interval;
242 self
243 }
244
245 #[must_use]
249 pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self {
250 self.install_progress = Some(progress);
251 self
252 }
253}
254
255#[derive(Debug, Clone, Default)]
256pub struct PackageQuery {
257 pub query: Option<String>,
258 pub id: Option<String>,
259 pub name: Option<String>,
260 pub moniker: Option<String>,
261 pub tag: Option<String>,
262 pub command: Option<String>,
263 pub source: Option<String>,
264 pub count: Option<usize>,
265 pub exact: bool,
266 pub version: Option<String>,
267 pub channel: Option<String>,
268 pub locale: Option<String>,
269 pub installer_type: Option<String>,
270 pub installer_architecture: Option<String>,
271 pub platform: Option<String>,
272 pub os_version: Option<String>,
273 pub install_scope: Option<String>,
274}
275
276#[derive(Debug, Clone, Default)]
277pub struct ListQuery {
278 pub query: Option<String>,
279 pub id: Option<String>,
280 pub name: Option<String>,
281 pub moniker: Option<String>,
282 pub tag: Option<String>,
283 pub command: Option<String>,
284 pub product_code: Option<String>,
285 pub version: Option<String>,
286 pub source: Option<String>,
287 pub count: Option<usize>,
288 pub exact: bool,
289 pub install_scope: Option<String>,
290 pub upgrade_only: bool,
291 pub include_unknown: bool,
292 pub include_pinned: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296#[serde(rename_all = "PascalCase")]
297pub struct SearchMatch {
298 pub source_name: String,
299 pub source_kind: SourceKind,
300 pub id: String,
301 pub name: String,
302 pub moniker: Option<String>,
303 pub version: Option<String>,
304 pub channel: Option<String>,
305 pub match_criteria: Option<String>,
306}
307
308#[derive(Debug, Clone, serde::Serialize)]
309#[serde(rename_all = "PascalCase")]
310pub struct SearchResponse {
311 pub matches: Vec<SearchMatch>,
312 pub warnings: Vec<String>,
313 pub truncated: bool,
314}
315
316#[derive(Debug, Clone, serde::Serialize)]
317#[serde(rename_all = "PascalCase")]
318pub struct ListMatch {
319 pub name: String,
320 pub id: String,
321 pub local_id: String,
322 pub installed_version: String,
323 pub available_version: Option<String>,
324 pub source_name: Option<String>,
325 pub publisher: Option<String>,
326 pub scope: Option<String>,
327 pub installer_category: Option<String>,
328 pub install_location: Option<String>,
329 pub package_family_names: Vec<String>,
330 pub product_codes: Vec<String>,
331 pub upgrade_codes: Vec<String>,
332}
333
334#[derive(Debug, Clone, serde::Serialize)]
335#[serde(rename_all = "PascalCase")]
336pub struct ListResponse {
337 pub matches: Vec<ListMatch>,
338 pub warnings: Vec<String>,
339 pub truncated: bool,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
343#[serde(rename_all = "PascalCase")]
344pub struct VersionKey {
345 pub version: String,
346 pub channel: String,
347}
348
349#[derive(Debug, Clone, serde::Serialize)]
350pub struct Manifest {
351 pub id: String,
352 pub name: String,
353 pub version: String,
354 pub channel: String,
355 pub publisher: Option<String>,
356 pub description: Option<String>,
357 pub moniker: Option<String>,
358 pub package_url: Option<String>,
359 pub publisher_url: Option<String>,
360 pub publisher_support_url: Option<String>,
361 pub license: Option<String>,
362 pub license_url: Option<String>,
363 pub privacy_url: Option<String>,
364 pub author: Option<String>,
365 pub copyright: Option<String>,
366 pub copyright_url: Option<String>,
367 pub release_notes: Option<String>,
368 pub release_notes_url: Option<String>,
369 pub tags: Vec<String>,
370 pub agreements: Vec<PackageAgreement>,
371 pub package_dependencies: Vec<String>,
372 pub documentation: Vec<Documentation>,
373 pub icons: Vec<PackageIcon>,
374 pub installers: Vec<Installer>,
375 pub require_explicit_upgrade: bool,
380}
381
382impl Manifest {
383 pub fn icon(&self) -> Option<&PackageIcon> {
384 select_best_icon(&self.icons)
385 }
386
387 pub fn icon_url(&self) -> Option<&str> {
388 self.icon().and_then(|icon| icon.icon_url.as_deref())
389 }
390}
391
392#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
393#[serde(rename_all = "PascalCase")]
394pub struct PackageIcon {
395 pub icon_url: Option<String>,
396 pub icon_file_type: Option<String>,
397 pub icon_resolution: Option<String>,
398 pub icon_theme: Option<String>,
399 pub icon_sha256: Option<String>,
400}
401
402impl PackageIcon {
403 fn is_empty(&self) -> bool {
404 self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
405 && self
406 .icon_file_type
407 .as_deref()
408 .is_none_or(|value| value.trim().is_empty())
409 && self
410 .icon_resolution
411 .as_deref()
412 .is_none_or(|value| value.trim().is_empty())
413 && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
414 && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
415 }
416}
417
418fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
419 icons
420 .iter()
421 .enumerate()
422 .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
423 .max_by(|(left_index, left), (right_index, right)| {
424 icon_theme_score(left.icon_theme.as_deref())
425 .cmp(&icon_theme_score(right.icon_theme.as_deref()))
426 .then_with(|| {
427 icon_resolution_score(left.icon_resolution.as_deref())
428 .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
429 })
430 .then_with(|| {
431 icon_file_type_score(left.icon_file_type.as_deref())
432 .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
433 })
434 .then_with(|| right_index.cmp(left_index))
435 })
436 .map(|(_, icon)| icon)
437}
438
439fn icon_theme_score(theme: Option<&str>) -> usize {
440 match theme {
441 None => 1,
442 Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
443 Some(_) => 0,
444 }
445}
446
447fn icon_file_type_score(file_type: Option<&str>) -> usize {
448 match file_type {
449 Some(value) if value.eq_ignore_ascii_case("ico") => 2,
450 Some(value) if value.eq_ignore_ascii_case("png") => 1,
451 _ => 0,
452 }
453}
454
455fn icon_resolution_score(resolution: Option<&str>) -> usize {
456 let Some(resolution) = resolution else {
457 return 0;
458 };
459 let resolution = resolution.trim();
460 let Some(separator) = resolution.find(['x', 'X']) else {
461 return 0;
462 };
463 let (width, height) = resolution.split_at(separator);
464 let height = &height[1..];
465 let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
466 return 0;
467 };
468 if width == height { width } else { 0 }
469}
470
471#[derive(Debug, Clone, serde::Serialize)]
472pub struct Documentation {
473 pub label: Option<String>,
474 pub url: String,
475}
476
477#[derive(Debug, Clone, serde::Serialize)]
478pub struct PackageAgreement {
479 pub label: Option<String>,
480 pub text: Option<String>,
481 pub url: Option<String>,
482}
483
484#[derive(Debug, Clone, Default, serde::Serialize)]
485pub struct Installer {
486 pub architecture: Option<String>,
487 pub installer_type: Option<String>,
488 pub nested_installer_type: Option<String>,
489 pub nested_installer_files: Vec<NestedInstallerFile>,
490 pub url: Option<String>,
491 pub sha256: Option<String>,
492 pub product_code: Option<String>,
493 pub locale: Option<String>,
494 pub scope: Option<String>,
495 pub release_date: Option<String>,
496 pub package_family_name: Option<String>,
497 pub upgrade_code: Option<String>,
498 pub platforms: Vec<String>,
499 pub minimum_os_version: Option<String>,
500 pub switches: InstallerSwitches,
501 pub commands: Vec<String>,
502 pub package_dependencies: Vec<String>,
503 pub require_explicit_upgrade: bool,
504}
505
506#[derive(Debug, Clone, Default, serde::Serialize)]
507pub struct NestedInstallerFile {
508 pub relative_file_path: String,
510 pub portable_command_alias: Option<String>,
514}
515
516#[derive(Debug, Clone, Default, serde::Serialize)]
517pub struct InstallerSwitches {
518 pub silent: Option<String>,
519 pub silent_with_progress: Option<String>,
520 pub interactive: Option<String>,
521 pub custom: Option<String>,
522 pub log: Option<String>,
523 pub install_location: Option<String>,
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum InstallerMode {
528 Interactive,
529 SilentWithProgress,
530 Silent,
531}
532
533#[derive(Debug, Clone)]
534pub struct InstallRequest {
535 pub query: PackageQuery,
536 pub manifest_path: Option<PathBuf>,
537 pub mode: InstallerMode,
538 pub log_path: Option<PathBuf>,
539 pub custom: Option<String>,
540 pub override_args: Option<String>,
541 pub install_location: Option<String>,
542 pub skip_dependencies: bool,
543 pub dependencies_only: bool,
544 pub accept_package_agreements: bool,
545 pub force: bool,
546 pub rename: Option<String>,
547 pub uninstall_previous: bool,
548 pub ignore_security_hash: bool,
549 pub dependency_source: Option<String>,
550 pub no_upgrade: bool,
551}
552
553impl InstallRequest {
554 pub fn new(query: PackageQuery) -> Self {
555 Self {
556 query,
557 manifest_path: None,
558 mode: InstallerMode::SilentWithProgress,
559 log_path: None,
560 custom: None,
561 override_args: None,
562 install_location: None,
563 skip_dependencies: false,
564 dependencies_only: false,
565 accept_package_agreements: false,
566 force: false,
567 rename: None,
568 uninstall_previous: false,
569 ignore_security_hash: false,
570 dependency_source: None,
571 no_upgrade: false,
572 }
573 }
574}
575
576#[derive(Debug, Clone)]
577pub struct UninstallRequest {
578 pub query: PackageQuery,
579 pub manifest_path: Option<PathBuf>,
580 pub product_code: Option<String>,
581 pub mode: InstallerMode,
582 pub all_versions: bool,
583 pub force: bool,
584 pub purge: bool,
585 pub preserve: bool,
586 pub log_path: Option<PathBuf>,
587}
588
589impl UninstallRequest {
590 pub fn new(query: PackageQuery) -> Self {
591 Self {
592 query,
593 manifest_path: None,
594 product_code: None,
595 mode: InstallerMode::SilentWithProgress,
596 all_versions: false,
597 force: false,
598 purge: false,
599 preserve: false,
600 log_path: None,
601 }
602 }
603}
604
605#[derive(Debug, Clone)]
606pub struct RepairRequest {
607 pub query: PackageQuery,
608 pub manifest_path: Option<PathBuf>,
609 pub product_code: Option<String>,
610 pub mode: InstallerMode,
611 pub log_path: Option<PathBuf>,
612 pub accept_package_agreements: bool,
613 pub force: bool,
614 pub ignore_security_hash: bool,
615}
616
617impl RepairRequest {
618 pub fn new(query: PackageQuery) -> Self {
619 Self {
620 query,
621 manifest_path: None,
622 product_code: None,
623 mode: InstallerMode::SilentWithProgress,
624 log_path: None,
625 accept_package_agreements: false,
626 force: false,
627 ignore_security_hash: false,
628 }
629 }
630}
631
632#[derive(Debug, Clone, serde::Serialize)]
633pub struct ShowResult {
634 pub package: SearchMatch,
635 pub manifest: Manifest,
636 pub selected_installer: Option<Installer>,
637 pub cached_files: Vec<PathBuf>,
638 pub warnings: Vec<String>,
639 #[serde(skip_serializing)]
640 manifest_documents: JsonValue,
641}
642
643impl ShowResult {
644 pub fn structured_document(&self) -> JsonValue {
645 add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
646 }
647}
648
649#[derive(Debug, Clone, serde::Serialize)]
654pub struct DownloadOutput {
655 pub manifest: Manifest,
656 pub installer_path: PathBuf,
657 pub manifest_path: PathBuf,
658}
659
660struct FetchedInstaller {
664 manifest: Manifest,
665 manifest_documents: JsonValue,
666 installer: Installer,
667 installer_path: PathBuf,
668}
669
670#[derive(Debug, Clone, serde::Serialize)]
671#[serde(rename_all = "PascalCase")]
672pub struct VersionsResult {
673 pub package: SearchMatch,
674 pub versions: Vec<VersionKey>,
675 pub warnings: Vec<String>,
676}
677
678#[derive(Debug, Clone, serde::Serialize)]
679#[serde(rename_all = "PascalCase")]
680pub struct CacheWarmResult {
681 pub package: SearchMatch,
682 pub cached_files: Vec<PathBuf>,
683 pub warnings: Vec<String>,
684}
685
686#[derive(Debug, Clone, serde::Serialize)]
687#[serde(rename_all = "PascalCase")]
688pub struct SourceUpdateResult {
689 pub name: String,
690 pub kind: SourceKind,
691 pub detail: String,
692}
693
694#[derive(Debug, Clone, serde::Serialize)]
695#[serde(rename_all = "PascalCase")]
696pub struct PinRecord {
697 pub package_id: String,
698 pub version: String,
699 pub source_id: String,
700 pub pin_type: PinType,
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
704pub enum PinType {
705 Pinning,
706 Blocking,
707 Gating,
708}
709
710impl Display for PinType {
711 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
712 match self {
713 PinType::Pinning => f.write_str("Pinning"),
714 PinType::Blocking => f.write_str("Blocking"),
715 PinType::Gating => f.write_str("Gating"),
716 }
717 }
718}
719
720#[derive(Debug, Clone, serde::Serialize)]
722pub struct InstallResult {
723 pub package_id: String,
724 pub version: String,
725 pub installer_path: PathBuf,
726 pub installer_type: String,
727 pub exit_code: i32,
728 pub success: bool,
729 pub no_op: bool,
730 pub warnings: Vec<String>,
731}
732
733#[derive(Debug, Clone)]
734pub enum InstallProgress {
735 ResolvingManifest,
736 ResolvedManifest { package_id: String, version: String },
737 CheckingInstalled,
738 InstallingDependencies,
739 SelectingInstaller,
740 DownloadingInstaller { url: String, path: PathBuf },
741 VerifyingInstaller { path: PathBuf },
742 StartingInstaller { installer_type: String, path: PathBuf },
743 WaitingForInstaller { installer_type: String },
744 InstallerFinished { exit_code: i32 },
745}
746
747impl InstallerSwitches {
748 fn with_fallback(&self, fallback: &Self) -> Self {
749 Self {
750 silent: self.silent.clone().or_else(|| fallback.silent.clone()),
751 silent_with_progress: self
752 .silent_with_progress
753 .clone()
754 .or_else(|| fallback.silent_with_progress.clone()),
755 interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
756 custom: self.custom.clone().or_else(|| fallback.custom.clone()),
757 log: self.log.clone().or_else(|| fallback.log.clone()),
758 install_location: self
759 .install_location
760 .clone()
761 .or_else(|| fallback.install_location.clone()),
762 }
763 }
764}
765
766fn installed_package_discovery_supported() -> bool {
767 cfg!(windows)
768}
769
770fn package_actions_supported() -> bool {
771 cfg!(windows)
772}
773
774fn unsupported_action_result(
775 package_id: impl Into<String>,
776 version: impl Into<String>,
777 installer_type: impl Into<String>,
778 warning: &str,
779) -> InstallResult {
780 InstallResult {
781 package_id: package_id.into(),
782 version: version.into(),
783 installer_path: PathBuf::new(),
784 installer_type: installer_type.into(),
785 exit_code: 0,
786 success: true,
787 no_op: true,
788 warnings: vec![warning.to_owned()],
789 }
790}
791
792#[derive(Debug, Clone)]
793struct InstalledPackage {
794 name: String,
795 local_id: String,
796 installed_version: String,
797 publisher: Option<String>,
798 scope: Option<String>,
799 installer_category: Option<String>,
800 install_location: Option<String>,
801 winget_package_identifier: Option<String>,
802 winget_source_identifier: Option<String>,
803 package_family_names: Vec<String>,
804 product_codes: Vec<String>,
805 upgrade_codes: Vec<String>,
806 correlated: Option<SearchMatch>,
807 installed_version_canonical: bool,
814 correlated_requires_explicit_upgrade: bool,
818 correlated_lacks_compatible_installer: bool,
822}
823
824#[derive(Debug, Clone)]
825struct LocatedMatch {
826 display: SearchMatch,
827 source_index: usize,
828 locator: MatchLocator,
829}
830
831#[derive(Debug)]
832struct SearchSourceMatches {
833 matches: Vec<LocatedMatch>,
834 truncated: bool,
835}
836
837#[derive(Debug, Clone)]
838struct SourceSearchFailure {
839 source_name: String,
840 message: String,
841}
842
843#[derive(Debug)]
844struct SearchLocatedResult {
845 matches: Vec<LocatedMatch>,
846 warnings: Vec<String>,
847 failures: Vec<SourceSearchFailure>,
848 truncated: bool,
849}
850
851fn single_match_from_located(
852 located: SearchLocatedResult,
853 report_first_source_failure: bool,
854) -> Result<(LocatedMatch, Vec<String>)> {
855 if located.matches.is_empty() {
856 if report_first_source_failure && let Some(failure) = located.failures.first() {
857 bail!("failed to search source '{}': {}", failure.source_name, failure.message);
858 }
859 bail!("no package matched the supplied query");
860 }
861
862 if located.matches.len() > 1 {
863 let choices = located
864 .matches
865 .iter()
866 .take(10)
867 .map(|item| {
868 format!(
869 "{} [{}] ({})",
870 item.display.name, item.display.id, item.display.source_name
871 )
872 })
873 .collect::<Vec<_>>()
874 .join(", ");
875 bail!("multiple packages matched: {choices}");
876 }
877
878 Ok((located.matches.into_iter().next().expect("one match"), located.warnings))
879}
880
881fn should_retry_show_across_system_winget_sources(
882 uses_system_winget_sources: bool,
883 query: &PackageQuery,
884 located: &SearchLocatedResult,
885) -> bool {
886 uses_system_winget_sources
887 && query
888 .source
889 .as_deref()
890 .is_some_and(|source| source.eq_ignore_ascii_case("winget"))
891 && located.matches.is_empty()
892 && located.failures.is_empty()
893}
894
895#[derive(Debug, Clone)]
896enum MatchLocator {
897 PreIndexedV1 {
898 package_rowid: i64,
899 },
900 PreIndexedV2 {
901 package_rowid: i64,
902 package_hash: String,
903 },
904 Rest {
905 package_id: String,
906 versions: Vec<VersionKey>,
907 },
908}
909
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911enum SearchSemantics {
912 Many,
913 Single,
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize)]
917struct RestInfoCache {
918 expires_at: DateTime<Utc>,
919 value: RestInformation,
920}
921
922#[derive(Debug, Clone, Default, Serialize, Deserialize)]
923struct RestInformation {
924 #[serde(rename = "SourceIdentifier")]
925 source_identifier: String,
926 #[serde(rename = "ServerSupportedVersions", default)]
927 server_supported_versions: Vec<String>,
928 #[serde(rename = "RequiredPackageMatchFields", default)]
929 required_package_match_fields: Vec<String>,
930 #[serde(rename = "UnsupportedPackageMatchFields", default)]
931 unsupported_package_match_fields: Vec<String>,
932 #[serde(rename = "RequiredQueryParameters", default)]
933 required_query_parameters: Vec<String>,
934 #[serde(rename = "UnsupportedQueryParameters", default)]
935 unsupported_query_parameters: Vec<String>,
936}
937
938#[derive(Debug, Clone, Deserialize)]
939struct PackageVersionDataDocument {
940 #[serde(rename = "vD", default)]
941 versions: Vec<PackageVersionDataEntry>,
942}
943
944#[derive(Debug, Clone, Deserialize)]
945struct PackageVersionDataEntry {
946 #[serde(rename = "v")]
947 version: String,
948 #[serde(rename = "rP")]
949 manifest_relative_path: String,
950 #[serde(rename = "s256H")]
951 manifest_hash: String,
952 #[serde(rename = "aMiV", default)]
957 arp_min_version: Option<String>,
958 #[serde(rename = "aMaV", default)]
959 arp_max_version: Option<String>,
960}
961
962pub struct Repository {
963 app_root: PathBuf,
964 user_agent: String,
965 client: Client,
966 store: SourceStore,
967 source_mode: EffectiveSourceMode,
968 install_progress: Option<fn(&InstallProgress)>,
969 pre_indexed_source_auto_update_interval: Option<Duration>,
970 preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
971}
972
973#[derive(Debug, Clone)]
974struct PreindexedRefreshAttempt {
975 attempted_at: DateTime<Utc>,
976 succeeded: bool,
977}
978
979#[derive(Debug, Clone, Default)]
980struct PreindexedRefreshOutcome {
981 attempted: bool,
982 reused_recent_attempt: bool,
983 error: Option<String>,
984}
985
986struct PreindexedOpenConnection {
987 connection: Connection,
988 refresh: PreindexedRefreshOutcome,
989}
990
991#[derive(Debug)]
992struct MissingPackageVersionError {
993 package_id: String,
994 source_name: String,
995 requested_version: String,
996 requested_channel: Option<String>,
997}
998
999impl Display for MissingPackageVersionError {
1000 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1001 let channel = self
1002 .requested_channel
1003 .as_deref()
1004 .filter(|value| !value.is_empty())
1005 .map(|value| format!(" with channel '{value}'"))
1006 .unwrap_or_default();
1007 write!(
1008 f,
1009 "Version '{}'{} was not found for package '{}' in source '{}'.",
1010 self.requested_version, channel, self.package_id, self.source_name
1011 )
1012 }
1013}
1014
1015impl std::error::Error for MissingPackageVersionError {}
1016
1017fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
1018 static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
1019 LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
1020}
1021
1022impl Repository {
1023 pub fn open() -> Result<Self> {
1025 Self::open_with_options(RepositoryOptions::for_current_user()?)
1026 }
1027
1028 pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
1030 ensure_app_dirs(&options.app_root)?;
1031 let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?;
1032 let client = Client::builder()
1033 .user_agent(&options.user_agent)
1034 .timeout(StdDuration::from_secs(30 * 60))
1035 .build()
1036 .context("failed to build HTTP client")?;
1037 Ok(Self {
1038 app_root: options.app_root,
1039 user_agent: options.user_agent,
1040 client,
1041 store,
1042 source_mode,
1043 install_progress: options.install_progress,
1044 pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
1045 preindexed_refresh_attempts: HashMap::new(),
1046 })
1047 }
1048
1049 fn uses_system_winget_sources(&self) -> bool {
1050 self.source_mode != EffectiveSourceMode::Private
1051 }
1052
1053 fn mirrors_system_winget_sources(&self) -> bool {
1054 self.source_mode == EffectiveSourceMode::SystemWingetMirror
1055 }
1056
1057 fn emit_install_progress(&self, progress: InstallProgress) {
1058 if let Some(callback) = self.install_progress {
1059 callback(&progress);
1060 }
1061 }
1062
1063 pub fn app_root(&self) -> &Path {
1064 &self.app_root
1065 }
1066
1067 pub fn list_sources(&self) -> Vec<SourceRecord> {
1068 self.store.sources.clone()
1069 }
1070
1071 pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
1072 self.add_source_with_metadata(name, arg, kind, None, false, 0)
1073 }
1074
1075 pub fn add_source_with_metadata(
1076 &mut self,
1077 name: &str,
1078 arg: &str,
1079 kind: SourceKind,
1080 trust_level: Option<&str>,
1081 explicit: bool,
1082 priority: i32,
1083 ) -> Result<()> {
1084 if self.uses_system_winget_sources() {
1085 system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
1086 self.refresh_system_winget_sources()?;
1087 return Ok(());
1088 }
1089
1090 if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
1091 bail!("A source with name '{name}' already exists.");
1092 }
1093 if self.store.sources.iter().any(|s| s.arg == arg) {
1094 bail!("A source with argument '{arg}' already exists.");
1095 }
1096 let identifier = name.to_owned();
1097 self.store.sources.push(SourceRecord {
1098 name: name.to_owned(),
1099 kind,
1100 arg: arg.to_owned(),
1101 identifier,
1102 trust_level: normalize_source_trust_level(trust_level)?,
1103 explicit,
1104 priority,
1105 last_update: None,
1106 source_version: None,
1107 etag: None,
1108 last_modified: None,
1109 });
1110 if self.source_mode != EffectiveSourceMode::SystemWingetDirect {
1111 self.save_store()?;
1112 }
1113 Ok(())
1114 }
1115
1116 pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
1117 if self.uses_system_winget_sources() {
1118 bail!("Editing system WinGet sources is not supported by winget source commands.");
1119 }
1120
1121 let source = self
1122 .store
1123 .sources
1124 .iter_mut()
1125 .find(|source| source.name.eq_ignore_ascii_case(name))
1126 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1127
1128 if let Some(explicit) = explicit {
1129 source.explicit = explicit;
1130 }
1131
1132 if trust_level.is_some() {
1133 source.trust_level = normalize_source_trust_level(trust_level)?;
1134 }
1135
1136 self.save_store()?;
1137 Ok(())
1138 }
1139
1140 pub fn remove_source(&mut self, name: &str) -> Result<()> {
1141 if self.uses_system_winget_sources() {
1142 system_winget_remove_source(name)?;
1143 self.refresh_system_winget_sources()?;
1144 return Ok(());
1145 }
1146
1147 let idx = self
1148 .store
1149 .sources
1150 .iter()
1151 .position(|s| s.name.eq_ignore_ascii_case(name))
1152 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1153 let source = self.store.sources.remove(idx);
1154 let state_dir = self.source_state_dir(&source);
1156 if state_dir.exists() {
1157 let _ = fs::remove_dir_all(&state_dir);
1158 }
1159 self.save_store()?;
1160 Ok(())
1161 }
1162
1163 pub fn reset_source(&mut self, name: &str) -> Result<()> {
1164 if self.uses_system_winget_sources() {
1165 system_winget_reset_source(name)?;
1166 self.refresh_system_winget_sources()?;
1167 return Ok(());
1168 }
1169
1170 let idx = self
1171 .store
1172 .sources
1173 .iter()
1174 .position(|source| source.name.eq_ignore_ascii_case(name))
1175 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1176
1177 let source = self.store.sources[idx].clone();
1178 let state_dir = self.source_state_dir(&source);
1179 if state_dir.exists() {
1180 let _ = fs::remove_dir_all(&state_dir);
1181 }
1182
1183 if let Some(default_source) = SourceStore::default()
1184 .sources
1185 .into_iter()
1186 .find(|source| source.name.eq_ignore_ascii_case(name))
1187 {
1188 self.store.sources[idx] = default_source;
1189 } else {
1190 self.store.sources[idx].last_update = None;
1191 self.store.sources[idx].source_version = None;
1192 self.store.sources[idx].etag = None;
1193 self.store.sources[idx].last_modified = None;
1194 }
1195
1196 self.save_store()?;
1197 Ok(())
1198 }
1199
1200 pub fn reset_sources(&mut self) -> Result<()> {
1201 if self.uses_system_winget_sources() {
1202 system_winget_reset_sources()?;
1203 self.refresh_system_winget_sources()?;
1204 return Ok(());
1205 }
1206
1207 for source in &self.store.sources {
1209 let state_dir = self.source_state_dir(source);
1210 if state_dir.exists() {
1211 let _ = fs::remove_dir_all(&state_dir);
1212 }
1213 }
1214 self.store = SourceStore::default();
1215 self.save_store()?;
1216 Ok(())
1217 }
1218
1219 pub fn supported_admin_settings() -> &'static [&'static str] {
1220 SUPPORTED_ADMIN_SETTINGS
1221 }
1222
1223 pub fn get_user_settings(&self) -> Result<JsonValue> {
1224 Ok(JsonValue::Object(load_json_object(&user_settings_path(
1225 &self.app_root,
1226 ))?))
1227 }
1228
1229 pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1230 let update = match user_settings {
1231 JsonValue::Object(object) => object.clone(),
1232 _ => bail!("user settings must be a JSON object"),
1233 };
1234
1235 let effective = if merge {
1236 merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1237 } else {
1238 update
1239 };
1240 save_json_object(user_settings_path(&self.app_root), &effective)?;
1241 Ok(JsonValue::Object(effective))
1242 }
1243
1244 pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1245 let current = self.get_user_settings()?;
1246 Ok(if ignore_not_set {
1247 json_contains(¤t, expected)
1248 } else {
1249 current == *expected
1250 })
1251 }
1252
1253 pub fn get_admin_settings(&self) -> Result<JsonValue> {
1254 let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1255 for name in SUPPORTED_ADMIN_SETTINGS {
1256 settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1257 }
1258
1259 Ok(JsonValue::Object(settings))
1260 }
1261
1262 pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1263 let normalized = normalize_admin_setting_name(name)?;
1264 let mut settings = match self.get_admin_settings()? {
1265 JsonValue::Object(settings) => settings,
1266 _ => JsonMap::new(),
1267 };
1268 settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1269 save_json_object(admin_settings_path(&self.app_root), &settings)
1270 }
1271
1272 pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1273 if !reset_all && name.is_none() {
1274 bail!("resetting admin settings requires a setting name or reset-all");
1275 }
1276
1277 let mut settings = match self.get_admin_settings()? {
1278 JsonValue::Object(settings) => settings,
1279 _ => JsonMap::new(),
1280 };
1281
1282 if reset_all {
1283 for setting_name in SUPPORTED_ADMIN_SETTINGS {
1284 settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1285 }
1286 } else {
1287 settings.insert(
1288 normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1289 JsonValue::Bool(false),
1290 );
1291 }
1292
1293 save_json_object(admin_settings_path(&self.app_root), &settings)
1294 }
1295
1296 pub fn ensure_settings_files(&self) -> Result<()> {
1297 let user_settings = user_settings_path(&self.app_root);
1298 if !user_settings.exists() {
1299 save_json_object(user_settings, &JsonMap::new())?;
1300 }
1301
1302 let admin_settings = admin_settings_path(&self.app_root);
1303 if !admin_settings.exists() {
1304 let mut values = JsonMap::new();
1305 for name in SUPPORTED_ADMIN_SETTINGS {
1306 values.insert((*name).to_owned(), JsonValue::Bool(false));
1307 }
1308 save_json_object(admin_settings, &values)?;
1309 }
1310
1311 Ok(())
1312 }
1313
1314 pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1315 if self.source_mode == EffectiveSourceMode::SystemWingetDirect {
1316 return self.update_system_winget_sources(source_name);
1317 }
1318
1319 if self.mirrors_system_winget_sources() {
1320 self.refresh_system_winget_sources()?;
1321 }
1322
1323 let indexes = self.resolve_source_indexes(source_name)?;
1324 let mut results = Vec::new();
1325
1326 for index in indexes {
1327 let detail = match self.store.sources[index].kind {
1328 SourceKind::PreIndexed => self.update_preindexed(index)?,
1329 SourceKind::Rest => self.update_rest(index)?,
1330 };
1331
1332 results.push(SourceUpdateResult {
1333 name: self.store.sources[index].name.clone(),
1334 kind: self.store.sources[index].kind,
1335 detail,
1336 });
1337 }
1338
1339 self.save_store()?;
1340 Ok(results)
1341 }
1342
1343 pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1344 let located = self.search_located(query, SearchSemantics::Many)?;
1345 Ok(SearchResponse {
1346 matches: located.matches.into_iter().map(|item| item.display).collect(),
1347 warnings: located.warnings,
1348 truncated: located.truncated,
1349 })
1350 }
1351
1352 pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1353 let located = self.search_located(query, SearchSemantics::Many)?;
1354 let mut structured_documents = Vec::with_capacity(located.matches.len());
1355 for located in located.matches {
1356 let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1357 structured_documents.push(manifest_documents);
1358 }
1359
1360 Ok(collapse_structured_documents(&structured_documents))
1361 }
1362
1363 pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1364 if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1365 bail!("--include-unknown and --include-pinned require --upgrade-available");
1366 }
1367 if query.source.is_some()
1368 && !query.upgrade_only
1369 && query.query.is_none()
1370 && query.id.is_none()
1371 && query.name.is_none()
1372 && query.moniker.is_none()
1373 && query.tag.is_none()
1374 && query.command.is_none()
1375 {
1376 bail!("list --source currently requires a query or explicit filter");
1377 }
1378
1379 self.refresh_system_winget_sources()?;
1380
1381 let has_filter = list_query_needs_available_lookup(query);
1382 let needs_available = has_filter || query.upgrade_only;
1383
1384 let mut warnings = Vec::new();
1385 if !installed_package_discovery_supported() {
1386 warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1387 }
1388 let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1389
1390 warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?);
1395 warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1396 warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1397
1398 if needs_available && has_filter {
1399 let available_query = package_query_from_list_query(query);
1402 let located = self.search_located(&available_query, SearchSemantics::Many)?;
1403 warnings.extend(located.warnings);
1404 let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1405 for package in &mut installed {
1406 if package.correlated.is_some() {
1407 continue;
1408 }
1409 package.correlated =
1410 correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1411 }
1412 } else if needs_available && !query.upgrade_only {
1413 warnings.extend(self.correlate_all_installed(&mut installed)?);
1418 }
1419
1420 if needs_available {
1421 warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1432 }
1433
1434 if query.upgrade_only {
1441 installed = dedupe_correlated_for_upgrade(installed);
1442 }
1443
1444 let mut matches = installed
1445 .into_iter()
1446 .filter(|package| {
1447 list_package_matches(package, query)
1448 && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1449 })
1450 .collect::<Vec<_>>();
1451
1452 matches.sort_by(|left, right| {
1453 list_sort_weight(left)
1454 .cmp(&list_sort_weight(right))
1455 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1456 .then_with(|| left.local_id.cmp(&right.local_id))
1457 });
1458
1459 let pins = if query.upgrade_only {
1460 self.list_pins(query.source.as_deref())?
1461 } else {
1462 Vec::new()
1463 };
1464 let mut list_matches = matches
1465 .into_iter()
1466 .map(|package| list_match_from_installed(package, &self.store.sources))
1467 .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1468 .collect::<Vec<_>>();
1469 if !query.upgrade_only {
1470 list_matches = suppress_duplicate_available_versions(list_matches);
1471 }
1472 let truncated = if let Some(limit) = query.count {
1473 let was_truncated = list_matches.len() > limit;
1474 list_matches.truncate(limit);
1475 was_truncated
1476 } else {
1477 false
1478 };
1479
1480 Ok(ListResponse {
1481 matches: list_matches,
1482 warnings,
1483 truncated,
1484 })
1485 }
1486
1487 fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1491 if installed.iter().all(|p| p.correlated.is_some()) {
1492 return Ok(Vec::new());
1493 }
1494 let all_query = PackageQuery {
1495 query: None,
1496 id: None,
1497 name: None,
1498 moniker: None,
1499 tag: None,
1500 command: None,
1501 source: None,
1502 count: Some(100_000), exact: false,
1504 version: None,
1505 channel: None,
1506 locale: None,
1507 installer_type: None,
1508 installer_architecture: None,
1509 platform: None,
1510 os_version: None,
1511 install_scope: None,
1512 };
1513 let located = self.search_located(&all_query, SearchSemantics::Many)?;
1514 let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1515
1516 for package in installed.iter_mut() {
1517 if package.correlated.is_some() {
1518 continue;
1519 }
1520 package.correlated = correlate_installed_package(package, &candidates, true);
1521 }
1522
1523 Ok(located.warnings)
1524 }
1525
1526 fn correlate_installed_via_installed_db(
1531 &mut self,
1532 installed: &mut [InstalledPackage],
1533 requested_source: Option<&str>,
1534 ) -> Result<Vec<String>> {
1535 let source_indices = self
1536 .store
1537 .sources
1538 .iter()
1539 .enumerate()
1540 .filter(|(_, source)| {
1541 requested_source
1542 .map(|name| source.name.eq_ignore_ascii_case(name))
1543 .unwrap_or(true)
1544 })
1545 .map(|(index, _)| index)
1546 .collect::<Vec<_>>();
1547
1548 for source_index in source_indices {
1549 let source = self.source_clone(source_index);
1550 let installed_db_path = self.source_installed_db_path(&source);
1551 if !installed_db_path.exists() {
1552 continue;
1553 }
1554
1555 let connection = Self::open_sqlite_connection(installed_db_path)?;
1556 if !installed_db_identity_tables_present(&connection)? {
1557 continue;
1558 }
1559
1560 for package in installed.iter_mut() {
1561 if package.correlated.is_some() {
1562 continue;
1563 }
1564 let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else {
1565 continue;
1566 };
1567 let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed {
1568 match self
1569 .open_preindexed_connection(source_index)
1570 .and_then(|index_connection| {
1571 let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else {
1572 return Ok((id.clone(), name.clone(), None, None));
1573 };
1574 let (canonical_installed, anchored_latest) =
1575 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1576 Ok((entries, _)) => (
1577 map_arp_version_to_catalog(&entries, &package.installed_version)
1578 .or_else(|| {
1579 less_than_latest_arp_anchored_version(
1580 &entries,
1581 &package.installed_version,
1582 )
1583 })
1584 .or_else(|| {
1585 greater_than_latest_arp_anchored_version(
1586 &entries,
1587 &package.installed_version,
1588 &meta.latest_version,
1589 )
1590 }),
1591 latest_arp_anchored_version(&entries),
1592 ),
1593 Err(_) => (None, None),
1594 };
1595 let version = match (&canonical_installed, anchored_latest) {
1596 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1597 (Some(_), Some(anchored)) => Some(anchored),
1598 (Some(_), None) => None,
1599 _ => Some(meta.latest_version.clone()),
1600 };
1601 if let Some(version) = canonical_installed {
1602 package.installed_version = version;
1603 package.installed_version_canonical = true;
1604 }
1605 Ok((meta.id, meta.name, meta.moniker, version))
1606 }) {
1607 Ok(enriched) => enriched,
1608 Err(_) => (id, name, None, None),
1609 }
1610 } else {
1611 (id, name, None, None)
1612 };
1613 package.correlated = Some(SearchMatch {
1614 source_name: source.name.clone(),
1615 source_kind: source.kind,
1616 id,
1617 name,
1618 moniker,
1619 version,
1620 channel: None,
1621 match_criteria: Some(matched_by.to_owned()),
1622 });
1623 }
1624 }
1625
1626 Ok(Vec::new())
1627 }
1628
1629 fn correlate_installed_via_index(
1638 &mut self,
1639 installed: &mut [InstalledPackage],
1640 requested_source: Option<&str>,
1641 ) -> Result<Vec<String>> {
1642 let source_indices: Vec<usize> = self
1643 .store
1644 .sources
1645 .iter()
1646 .enumerate()
1647 .filter(|(_, source)| {
1648 source.kind == SourceKind::PreIndexed
1649 && match requested_source {
1650 Some(name) => source.name.eq_ignore_ascii_case(name),
1651 None => true,
1652 }
1653 })
1654 .map(|(index, _)| index)
1655 .collect();
1656
1657 for source_index in source_indices {
1658 let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1659 let connection = match self.open_preindexed_connection(source_index) {
1660 Ok(c) => c,
1661 Err(_) => continue,
1662 };
1663 if !v2_identity_tables_present(&connection) {
1664 continue;
1665 }
1666 let mut acc = Vec::new();
1667 for (idx, package) in installed.iter().enumerate() {
1668 if package.correlated.is_some() {
1669 continue;
1670 }
1671 let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1672 continue;
1673 };
1674 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1675 continue;
1676 };
1677 acc.push((idx, meta, by));
1678 }
1679 acc
1680 };
1681
1682 let source = self.source_clone(source_index);
1683 for (idx, meta, by) in resolved {
1684 let (canonical_installed, anchored_latest) =
1685 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1686 Ok((entries, _)) => (
1687 map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1688 .or_else(|| {
1689 less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1690 })
1691 .or_else(|| {
1692 greater_than_latest_arp_anchored_version(
1693 &entries,
1694 &installed[idx].installed_version,
1695 &meta.latest_version,
1696 )
1697 }),
1698 latest_arp_anchored_version(&entries),
1699 ),
1700 Err(_) => (None, None),
1701 };
1702 let available_version = match (&canonical_installed, anchored_latest) {
1709 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1710 (Some(_), Some(anchored)) => Some(anchored),
1711 (Some(_), None) => None,
1712 _ => Some(meta.latest_version),
1713 };
1714 let installed_pkg = &mut installed[idx];
1715 installed_pkg.correlated = Some(SearchMatch {
1716 source_name: source.name.clone(),
1717 source_kind: source.kind,
1718 id: meta.id,
1719 name: meta.name,
1720 moniker: meta.moniker,
1721 version: available_version,
1722 channel: None,
1723 match_criteria: Some(by.to_owned()),
1724 });
1725 if let Some(version) = canonical_installed {
1726 installed_pkg.installed_version = version;
1727 installed_pkg.installed_version_canonical = true;
1728 }
1729 }
1730 }
1731
1732 Ok(Vec::new())
1733 }
1734
1735 fn correlate_installed_by_normalized_identity(
1744 &mut self,
1745 installed: &mut [InstalledPackage],
1746 requested_source: Option<&str>,
1747 ) -> Result<Vec<String>> {
1748 let source_indices: Vec<usize> = self
1749 .store
1750 .sources
1751 .iter()
1752 .enumerate()
1753 .filter(|(_, source)| {
1754 source.kind == SourceKind::PreIndexed
1755 && match requested_source {
1756 Some(name) => source.name.eq_ignore_ascii_case(name),
1757 None => true,
1758 }
1759 })
1760 .map(|(index, _)| index)
1761 .collect();
1762
1763 for source_index in source_indices {
1764 let resolved: Vec<(usize, V2PackageMetadata)> = {
1765 let connection = match self.open_preindexed_connection(source_index) {
1766 Ok(c) => c,
1767 Err(_) => continue,
1768 };
1769 if !v2_normalized_identity_tables_present(&connection) {
1770 continue;
1771 }
1772 let mut acc = Vec::new();
1773 for (idx, pkg) in installed.iter().enumerate() {
1774 if pkg.correlated.is_some() {
1775 continue;
1776 }
1777 if pkg.local_id.starts_with("MSIX\\") {
1782 continue;
1783 }
1784 let Some(publisher) = pkg.publisher.as_deref() else {
1785 continue;
1786 };
1787 let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1788 if normalized_name.is_empty() {
1789 continue;
1790 }
1791 let normalized_publisher = name_normalization::normalize_publisher(publisher);
1792 if normalized_publisher.is_empty() {
1793 continue;
1794 }
1795 let Some(rowid) =
1796 lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1797 else {
1798 continue;
1799 };
1800 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1801 continue;
1802 };
1803 acc.push((idx, meta));
1804 }
1805 acc
1806 };
1807
1808 let source = self.source_clone(source_index);
1809 for (idx, meta) in resolved {
1810 let (canonical_installed, anchored_latest) =
1811 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1812 Ok((entries, _)) => (
1813 map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1814 .or_else(|| {
1815 less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1816 })
1817 .or_else(|| {
1818 greater_than_latest_arp_anchored_version(
1819 &entries,
1820 &installed[idx].installed_version,
1821 &meta.latest_version,
1822 )
1823 }),
1824 latest_arp_anchored_version(&entries),
1825 ),
1826 Err(_) => (None, None),
1827 };
1828 let available_version = match (&canonical_installed, anchored_latest) {
1829 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1830 (Some(_), Some(anchored)) => Some(anchored),
1831 (Some(_), None) => None,
1832 _ => Some(meta.latest_version),
1833 };
1834 let installed_pkg = &mut installed[idx];
1835 installed_pkg.correlated = Some(SearchMatch {
1836 source_name: source.name.clone(),
1837 source_kind: source.kind,
1838 id: meta.id,
1839 name: meta.name,
1840 moniker: meta.moniker,
1841 version: available_version,
1842 channel: None,
1843 match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1844 });
1845 if let Some(version) = canonical_installed {
1846 installed_pkg.installed_version = version;
1847 installed_pkg.installed_version_canonical = true;
1848 }
1849 }
1850 }
1851
1852 Ok(Vec::new())
1853 }
1854
1855 fn enrich_correlated_via_index(
1867 &mut self,
1868 installed: &mut [InstalledPackage],
1869 requested_source: Option<&str>,
1870 ) -> Result<Vec<String>> {
1871 for package in installed.iter_mut() {
1873 apply_msix_resource_string_name_fix(package);
1874 }
1875
1876 let source_indices: Vec<usize> = self
1879 .store
1880 .sources
1881 .iter()
1882 .enumerate()
1883 .filter(|(_, source)| {
1884 source.kind == SourceKind::PreIndexed
1885 && match requested_source {
1886 Some(name) => source.name.eq_ignore_ascii_case(name),
1887 None => true,
1888 }
1889 })
1890 .map(|(index, _)| index)
1891 .collect();
1892
1893 for source_index in source_indices {
1894 let needs: Vec<(usize, V2PackageMetadata)> = {
1895 let connection = match self.open_preindexed_connection(source_index) {
1896 Ok(c) => c,
1897 Err(_) => continue,
1898 };
1899 if !v2_identity_tables_present(&connection) {
1900 continue;
1901 }
1902 let mut acc = Vec::new();
1903 for (idx, pkg) in installed.iter().enumerate() {
1904 let Some(candidate) = pkg.correlated.as_ref() else {
1905 continue;
1906 };
1907 if !candidate
1911 .source_name
1912 .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1913 {
1914 continue;
1915 }
1916 let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1917 continue;
1918 };
1919 acc.push((idx, meta));
1920 }
1921 acc
1922 };
1923
1924 let source = self.source_clone(source_index);
1925 for (idx, meta) in needs {
1926 let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1931 Ok((entries, _)) => entries,
1932 Err(_) => continue,
1933 };
1934
1935 if !installed[idx].installed_version_canonical {
1936 let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1937 .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version))
1938 .or_else(|| {
1939 greater_than_latest_arp_anchored_version(
1940 &entries,
1941 &installed[idx].installed_version,
1942 &meta.latest_version,
1943 )
1944 });
1945 let anchored_latest = latest_arp_anchored_version(&entries);
1946 if let Some(canonical) = canonical_installed {
1947 let greater_than_latest = is_greater_than_version_marker(&canonical);
1948 installed[idx].installed_version = canonical;
1949 installed[idx].installed_version_canonical = true;
1950 if let Some(correlated) = installed[idx].correlated.as_mut() {
1951 correlated.version = if greater_than_latest { None } else { anchored_latest };
1952 }
1953 }
1954 }
1955
1956 if let Some(latest) = entries.first() {
1962 let bytes_result = self.get_cached_source_file(
1963 "V2_M",
1964 &source,
1965 &latest.manifest_relative_path,
1966 Some(latest.manifest_hash.as_str()),
1967 );
1968 if let Ok(bytes) = bytes_result
1969 && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1970 {
1971 installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1972 installed[idx].correlated_lacks_compatible_installer =
1973 !manifest_has_compatible_installer(&manifest);
1974 }
1975 }
1976 }
1977 }
1978
1979 Ok(Vec::new())
1980 }
1981
1982 pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1983 let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1984 let versions = self.versions_for_match(&located, query)?;
1985 Ok(VersionsResult {
1986 package: located.display,
1987 versions,
1988 warnings,
1989 })
1990 }
1991
1992 pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1993 let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
1994 let versions = self.versions_for_match(&located, query)?;
1995 Ok(VersionsResult {
1996 package: located.display,
1997 versions,
1998 warnings,
1999 })
2000 }
2001
2002 pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
2003 let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
2004 let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
2005 let selected_installer = select_installer(&manifest.installers, query);
2006
2007 Ok(ShowResult {
2008 package: located.display,
2009 manifest,
2010 selected_installer,
2011 cached_files,
2012 warnings,
2013 manifest_documents,
2014 })
2015 }
2016
2017 pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
2018 let (located, warnings) = self.find_single_match(query)?;
2019 let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
2020
2021 Ok(CacheWarmResult {
2022 package: located.display,
2023 cached_files,
2024 warnings,
2025 })
2026 }
2027
2028 pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
2031 let db_path = pins_db_path(&self.app_root);
2032 if !db_path.exists() {
2033 return Ok(Vec::new());
2034 }
2035 let conn = Self::open_sqlite_connection(db_path)?;
2036 let pin_type_column = match resolve_pin_type_column(&conn)? {
2037 Some(column) => column,
2038 None => return Ok(Vec::new()),
2039 };
2040 let sql = if source_id.is_some() {
2041 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
2042 } else {
2043 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
2044 };
2045 let params = if let Some(source_id) = source_id {
2046 vec![SqlValue::Text(source_id.to_owned())]
2047 } else {
2048 Vec::new()
2049 };
2050 query_rows(&conn, &sql, params, |row| {
2051 let pin_type_int = row_i64(row, 3)?;
2052 Ok(PinRecord {
2053 package_id: row_string(row, 0)?,
2054 version: row_string(row, 1)?,
2055 source_id: row_string(row, 2)?,
2056 pin_type: decode_pin_type(pin_type_int),
2057 })
2058 })
2059 }
2060
2061 pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
2062 let db_path = pins_db_path(&self.app_root);
2063 let conn = Self::open_sqlite_connection(db_path)?;
2064 execute_batch_sql(
2065 &conn,
2066 "CREATE TABLE IF NOT EXISTS pin (
2067 package_id TEXT NOT NULL,
2068 version TEXT NOT NULL DEFAULT '*',
2069 source_id TEXT NOT NULL DEFAULT '',
2070 type INTEGER NOT NULL DEFAULT 0,
2071 PRIMARY KEY (package_id, source_id)
2072 )",
2073 )?;
2074 let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
2075 let type_int: i64 = match pin_type {
2076 PinType::Pinning => 2,
2077 PinType::Blocking => 4,
2078 PinType::Gating => 3,
2079 };
2080 execute_sql(
2081 &conn,
2082 &format!(
2083 "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
2084 ),
2085 vec![
2086 SqlValue::Text(package_id.to_owned()),
2087 SqlValue::Text(version.to_owned()),
2088 SqlValue::Text(source_id.to_owned()),
2089 SqlValue::Integer(type_int),
2090 ],
2091 )?;
2092 conn.cacheflush().context("failed to flush pin database")?;
2093 Ok(())
2094 }
2095
2096 pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
2097 let db_path = pins_db_path(&self.app_root);
2098 if !db_path.exists() {
2099 return Ok(false);
2100 }
2101 let conn = Self::open_sqlite_connection(db_path)?;
2102 let count = if let Some(source_id) = source_id {
2103 execute_sql(
2104 &conn,
2105 "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
2106 vec![
2107 SqlValue::Text(package_id.to_owned()),
2108 SqlValue::Text(source_id.to_owned()),
2109 ],
2110 )?
2111 } else {
2112 execute_sql(
2113 &conn,
2114 "DELETE FROM pin WHERE package_id = ?1",
2115 vec![SqlValue::Text(package_id.to_owned())],
2116 )?
2117 };
2118 conn.cacheflush().context("failed to flush pin database")?;
2119 Ok(count > 0)
2120 }
2121
2122 pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
2123 let db_path = pins_db_path(&self.app_root);
2124 if !db_path.exists() {
2125 return Ok(());
2126 }
2127
2128 if let Some(source_id) = source_id {
2129 let conn = Self::open_sqlite_connection(db_path)?;
2130 execute_sql(
2131 &conn,
2132 "DELETE FROM pin WHERE source_id = ?1",
2133 vec![SqlValue::Text(source_id.to_owned())],
2134 )?;
2135 conn.cacheflush().context("failed to flush pin database")?;
2136 } else {
2137 fs::remove_file(&db_path)?;
2138 }
2139 Ok(())
2140 }
2141
2142 fn save_store(&self) -> Result<()> {
2143 if self.mirrors_system_winget_sources() {
2144 save_system_winget_mirror_store(&self.app_root, &self.store)
2145 } else {
2146 save_store(&self.app_root, &self.store)
2147 }
2148 }
2149
2150 fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
2151 source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2152 }
2153
2154 fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf {
2155 source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2156 }
2157
2158 fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf {
2159 self.source_state_dir(source).join("source.msix")
2160 }
2161
2162 fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf {
2163 self.source_state_dir(source).join("index.db")
2164 }
2165
2166 fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf {
2167 self.source_state_dir(source).join("rest-information.json")
2168 }
2169
2170 fn rest_manifest_cache_path(
2171 &self,
2172 source: &SourceRecord,
2173 package_id: &str,
2174 version: &str,
2175 channel: &str,
2176 ) -> PathBuf {
2177 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
2178 let digest = sha256_hex(key.as_bytes());
2179 cache_root(&self.app_root)
2180 .join("REST_M")
2181 .join(sanitize_path_segment(&source.identifier))
2182 .join(format!("{digest}.json"))
2183 }
2184
2185 pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
2188 self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
2189 }
2190
2191 pub fn download_installer_for_request(
2192 &mut self,
2193 request: &InstallRequest,
2194 download_dir: &Path,
2195 ) -> Result<DownloadOutput> {
2196 let fetched = self.fetch_installer_for_request(request, download_dir)?;
2197 let manifest_yaml =
2198 build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
2199 .context("failed to render manifest YAML")?;
2200 let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
2201 let manifest_path = download_dir.join(&manifest_filename);
2202 fs::write(&manifest_path, manifest_yaml.as_bytes())
2203 .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
2204
2205 Ok(DownloadOutput {
2206 manifest: fetched.manifest,
2207 installer_path: fetched.installer_path,
2208 manifest_path,
2209 })
2210 }
2211
2212 fn fetch_installer_for_request(
2217 &mut self,
2218 request: &InstallRequest,
2219 download_dir: &Path,
2220 ) -> Result<FetchedInstaller> {
2221 let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
2222 let installer = select_installer(&manifest.installers, &request.query)
2223 .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
2224 let url = installer
2225 .url
2226 .as_deref()
2227 .ok_or_else(|| anyhow!("Installer has no URL"))?;
2228
2229 fs::create_dir_all(download_dir)?;
2230 let filename = request.rename.as_deref().unwrap_or_else(|| {
2231 url.rsplit('/')
2232 .next()
2233 .unwrap_or("installer")
2234 .split('?')
2235 .next()
2236 .unwrap_or("installer")
2237 });
2238 let dest = download_dir.join(filename);
2239
2240 self.emit_install_progress(InstallProgress::DownloadingInstaller {
2241 url: url.to_owned(),
2242 path: dest.clone(),
2243 });
2244 let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?;
2245
2246 self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() });
2247 if let Err(error) =
2248 verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash)
2249 {
2250 let _ = fs::remove_file(&dest);
2251 return Err(error);
2252 }
2253
2254 Ok(FetchedInstaller {
2255 manifest,
2256 manifest_documents,
2257 installer,
2258 installer_path: dest,
2259 })
2260 }
2261
2262 pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2263 let mut request = InstallRequest::new(query.clone());
2264 request.mode = if silent {
2265 InstallerMode::Silent
2266 } else {
2267 InstallerMode::SilentWithProgress
2268 };
2269 self.install_request(&request)
2270 }
2271
2272 pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
2273 let mut request = InstallRequest::new(query.clone());
2274 request.mode = mode;
2275 self.install_request(&request)
2276 }
2277
2278 pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
2279 self.emit_install_progress(InstallProgress::ResolvingManifest);
2280 let manifest = self.resolve_manifest_for_install(request)?;
2281 self.emit_install_progress(InstallProgress::ResolvedManifest {
2282 package_id: manifest.id.clone(),
2283 version: manifest.version.clone(),
2284 });
2285 if !package_actions_supported() {
2286 let installer_type = select_installer(&manifest.installers, &request.query)
2287 .and_then(|installer| installer.installer_type)
2288 .unwrap_or_else(|| "install".to_owned())
2289 .to_lowercase();
2290 return Ok(unsupported_action_result(
2291 manifest.id.clone(),
2292 manifest.version.clone(),
2293 installer_type,
2294 INSTALL_UNSUPPORTED_WARNING,
2295 ));
2296 }
2297
2298 self.emit_install_progress(InstallProgress::CheckingInstalled);
2299 let existing_match = self.find_installed_package_for_install(request, &manifest)?;
2300 if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
2301 return Ok(no_op_result);
2302 }
2303
2304 Self::ensure_package_agreements_accepted(&manifest, request)?;
2305 self.emit_install_progress(InstallProgress::InstallingDependencies);
2306 self.install_dependencies(&manifest, request, &mut HashSet::new())?;
2307
2308 if request.dependencies_only {
2309 return Ok(InstallResult {
2310 package_id: manifest.id.clone(),
2311 version: manifest.version.clone(),
2312 installer_path: PathBuf::new(),
2313 installer_type: "dependencies".to_owned(),
2314 exit_code: 0,
2315 success: true,
2316 no_op: false,
2317 warnings: Vec::new(),
2318 });
2319 }
2320
2321 self.emit_install_progress(InstallProgress::SelectingInstaller);
2322 let installer = select_installer(&manifest.installers, &request.query)
2323 .ok_or_else(|| anyhow!("No applicable installer found"))?;
2324
2325 if request.uninstall_previous {
2326 let mut uninstall_request = UninstallRequest::new(PackageQuery {
2327 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2328 query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
2329 source: request.query.source.clone(),
2330 exact: true,
2331 version: request.query.version.clone(),
2332 ..PackageQuery::default()
2333 });
2334 uninstall_request.product_code = installer.product_code.clone();
2335 uninstall_request.mode = InstallerMode::Silent;
2336 uninstall_request.all_versions = true;
2337 uninstall_request.force = true;
2338 let _ = self.uninstall_request(&uninstall_request);
2339 }
2340
2341 let temp_dir = std::env::temp_dir().join("pinget-install");
2342 let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
2343
2344 let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
2345
2346 self.emit_install_progress(InstallProgress::StartingInstaller {
2347 installer_type: installer_type.clone(),
2348 path: installer_path.clone(),
2349 });
2350 self.emit_install_progress(InstallProgress::WaitingForInstaller {
2351 installer_type: installer_type.clone(),
2352 });
2353 let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
2354 self.emit_install_progress(InstallProgress::InstallerFinished { exit_code });
2355
2356 Ok(InstallResult {
2357 package_id: manifest.id.clone(),
2358 version: manifest.version.clone(),
2359 installer_path,
2360 installer_type,
2361 exit_code,
2362 success: exit_code == 0,
2363 no_op: false,
2364 warnings: Vec::new(),
2365 })
2366 }
2367
2368 fn find_installed_package_for_install(
2369 &mut self,
2370 request: &InstallRequest,
2371 manifest: &Manifest,
2372 ) -> Result<Option<ListMatch>> {
2373 let installed_matches = self.list(&ListQuery {
2374 query: request.query.query.clone(),
2375 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2376 name: request.query.name.clone(),
2377 moniker: request.query.moniker.clone(),
2378 tag: None,
2379 command: None,
2380 product_code: None,
2381 version: None,
2382 source: request.query.source.clone(),
2383 count: Some(100),
2384 exact: request.query.exact || !manifest.id.trim().is_empty(),
2385 install_scope: request.query.install_scope.clone(),
2386 upgrade_only: false,
2387 include_unknown: false,
2388 include_pinned: false,
2389 })?;
2390
2391 Ok(installed_matches.matches.into_iter().find(|candidate| {
2392 candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2393 }))
2394 }
2395
2396 fn create_install_no_op_result(
2397 request: &InstallRequest,
2398 manifest: &Manifest,
2399 existing_match: Option<&ListMatch>,
2400 ) -> Option<InstallResult> {
2401 let existing_match = existing_match?;
2402
2403 if request.no_upgrade {
2404 return Some(InstallResult {
2405 package_id: manifest.id.clone(),
2406 version: existing_match.installed_version.clone(),
2407 installer_path: PathBuf::new(),
2408 installer_type: "install".to_owned(),
2409 exit_code: 0,
2410 success: true,
2411 no_op: true,
2412 warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2413 });
2414 }
2415
2416 if !request.force
2417 && !existing_match.installed_version.trim().is_empty()
2418 && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2419 {
2420 return Some(InstallResult {
2421 package_id: manifest.id.clone(),
2422 version: existing_match.installed_version.clone(),
2423 installer_path: PathBuf::new(),
2424 installer_type: "install".to_owned(),
2425 exit_code: 0,
2426 success: true,
2427 no_op: true,
2428 warnings: vec![
2429 "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2430 ],
2431 });
2432 }
2433
2434 None
2435 }
2436
2437 pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2438 if request.manifest_path.is_none() && !package_actions_supported() {
2439 let package_id = request
2440 .query
2441 .id
2442 .clone()
2443 .or_else(|| request.query.name.clone())
2444 .or_else(|| request.query.moniker.clone())
2445 .or_else(|| request.query.query.clone())
2446 .or_else(|| request.product_code.clone())
2447 .unwrap_or_else(|| "repair".to_owned());
2448 return Ok(unsupported_action_result(
2449 package_id,
2450 request.query.version.clone().unwrap_or_default(),
2451 "repair".to_owned(),
2452 REPAIR_UNSUPPORTED_WARNING,
2453 ));
2454 }
2455
2456 let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2457 let installed_match = if request.manifest_path.is_some() {
2458 None
2459 } else {
2460 let installed = self.list(&Self::create_repair_list_query(request))?;
2461 warnings.extend(installed.warnings);
2462 if installed.matches.is_empty() {
2463 bail!("No installed package matched the supplied repair query.");
2464 }
2465 if installed.matches.len() > 1 {
2466 bail!("Multiple installed packages matched the supplied repair query.");
2467 }
2468 installed.matches.into_iter().next()
2469 };
2470
2471 let mut result =
2472 self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2473 warnings.extend(result.warnings);
2474 result.warnings = warnings;
2475 Ok(result)
2476 }
2477
2478 pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2479 let mut request = UninstallRequest::new(query.clone());
2480 request.mode = if silent {
2481 InstallerMode::Silent
2482 } else {
2483 InstallerMode::SilentWithProgress
2484 };
2485 self.uninstall_request(&request)
2486 }
2487
2488 pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2489 if !package_actions_supported() {
2490 let (package_id, version) = if let Some(path) = &request.manifest_path {
2491 let manifest = Self::load_manifest_from_path(path)?;
2492 (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2493 } else {
2494 (
2495 request
2496 .query
2497 .id
2498 .clone()
2499 .or_else(|| request.query.query.clone())
2500 .or_else(|| request.query.name.clone())
2501 .or_else(|| request.query.moniker.clone())
2502 .or_else(|| request.product_code.clone())
2503 .unwrap_or_else(|| "unknown".to_owned()),
2504 request.query.version.clone().unwrap_or_default(),
2505 )
2506 };
2507
2508 return Ok(unsupported_action_result(
2509 package_id,
2510 version,
2511 "uninstall",
2512 UNINSTALL_UNSUPPORTED_WARNING,
2513 ));
2514 }
2515
2516 let matches = self.resolve_uninstall_matches(request)?;
2517 let mut exit_code = 0;
2518
2519 for installed in &matches {
2520 exit_code = uninstall_package(installed, request)?;
2521 if exit_code != 0 && !request.all_versions {
2522 break;
2523 }
2524 }
2525
2526 let installed = matches
2527 .first()
2528 .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2529
2530 Ok(InstallResult {
2531 package_id: installed.id.clone(),
2532 version: installed.installed_version.clone(),
2533 installer_path: PathBuf::new(),
2534 installer_type: "uninstall".to_owned(),
2535 exit_code,
2536 success: exit_code == 0,
2537 no_op: false,
2538 warnings: Vec::new(),
2539 })
2540 }
2541
2542 fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2543 self.resolve_manifest_and_documents_for_install(request)
2544 .map(|(manifest, _)| manifest)
2545 }
2546
2547 fn resolve_manifest_and_documents_for_install(
2548 &mut self,
2549 request: &InstallRequest,
2550 ) -> Result<(Manifest, JsonValue)> {
2551 if let Some(path) = &request.manifest_path {
2552 return Self::load_manifest_from_path_with_documents(path);
2553 }
2554
2555 let (located, _warnings) = self.find_single_match(&request.query)?;
2556 let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2557 Ok((manifest, documents))
2558 }
2559
2560 fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2561 if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2562 bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2563 }
2564 Ok(())
2565 }
2566
2567 fn install_dependencies(
2568 &mut self,
2569 manifest: &Manifest,
2570 request: &InstallRequest,
2571 visited: &mut HashSet<String>,
2572 ) -> Result<()> {
2573 if request.skip_dependencies {
2574 return Ok(());
2575 }
2576
2577 let dependency_ids = manifest
2578 .package_dependencies
2579 .iter()
2580 .chain(
2581 manifest
2582 .installers
2583 .iter()
2584 .flat_map(|installer| installer.package_dependencies.iter()),
2585 )
2586 .filter(|value| !value.trim().is_empty())
2587 .cloned()
2588 .collect::<BTreeSet<_>>();
2589
2590 for dependency_id in dependency_ids {
2591 if !visited.insert(dependency_id.clone()) {
2592 continue;
2593 }
2594
2595 let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2596 self.install_request(&dependency_request)?;
2597 }
2598
2599 Ok(())
2600 }
2601
2602 fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2603 let mut dependency_request = InstallRequest::new(PackageQuery {
2604 id: Some(dependency_id),
2605 source: request
2606 .dependency_source
2607 .clone()
2608 .or_else(|| request.query.source.clone()),
2609 exact: true,
2610 ..PackageQuery::default()
2611 });
2612 dependency_request.mode = match request.mode {
2613 InstallerMode::Interactive => InstallerMode::Interactive,
2614 InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2615 };
2616 dependency_request.accept_package_agreements = request.accept_package_agreements;
2617 dependency_request.no_upgrade = true;
2618 dependency_request.ignore_security_hash = request.ignore_security_hash;
2619 dependency_request.dependency_source = request.dependency_source.clone();
2620 dependency_request
2621 }
2622
2623 fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2624 ListQuery {
2625 query: request.query.query.clone(),
2626 id: request.query.id.clone(),
2627 name: request.query.name.clone(),
2628 moniker: request.query.moniker.clone(),
2629 product_code: request.product_code.clone(),
2630 version: request.query.version.clone(),
2631 source: request.query.source.clone(),
2632 count: Some(100),
2633 exact: request.query.exact,
2634 install_scope: request.query.install_scope.clone(),
2635 ..ListQuery::default()
2636 }
2637 }
2638
2639 fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2640 let mut query = request.query.clone();
2641 query.query = if installed_match.is_none() {
2642 request.query.query.clone()
2643 } else {
2644 None
2645 };
2646 query.id = installed_match
2647 .map(|item| item.id.clone())
2648 .or_else(|| request.query.id.clone());
2649 query.name = if installed_match.is_none() {
2650 request.query.name.clone()
2651 } else {
2652 None
2653 };
2654 query.moniker = if installed_match.is_none() {
2655 request.query.moniker.clone()
2656 } else {
2657 None
2658 };
2659 query.source = request
2660 .query
2661 .source
2662 .clone()
2663 .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2664 query.exact = true;
2665 query.version = request
2666 .query
2667 .version
2668 .clone()
2669 .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2670
2671 InstallRequest {
2672 query,
2673 manifest_path: request.manifest_path.clone(),
2674 mode: request.mode,
2675 log_path: request.log_path.clone(),
2676 custom: None,
2677 override_args: None,
2678 install_location: None,
2679 skip_dependencies: false,
2680 dependencies_only: false,
2681 accept_package_agreements: request.accept_package_agreements,
2682 force: true,
2683 rename: None,
2684 uninstall_previous: false,
2685 ignore_security_hash: request.ignore_security_hash,
2686 dependency_source: None,
2687 no_upgrade: false,
2688 }
2689 }
2690
2691 fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2692 let mut effective_query = request.query.clone();
2693 let mut effective_product_code = request.product_code.clone();
2694
2695 if let Some(path) = &request.manifest_path {
2696 let manifest = Self::load_manifest_from_path(path)?;
2697 effective_query = PackageQuery {
2698 query: Some(manifest.id.clone()),
2699 id: Some(manifest.id.clone()),
2700 name: Some(manifest.name.clone()),
2701 exact: true,
2702 version: effective_query.version.clone().or(Some(manifest.version)),
2703 ..PackageQuery::default()
2704 };
2705 effective_product_code = effective_product_code.or_else(|| {
2706 manifest
2707 .installers
2708 .iter()
2709 .find_map(|installer| installer.product_code.clone())
2710 });
2711 }
2712
2713 let list_query = ListQuery {
2714 query: effective_query.query.clone(),
2715 id: effective_query.id.clone(),
2716 name: effective_query.name.clone(),
2717 moniker: effective_query.moniker.clone(),
2718 tag: None,
2719 command: None,
2720 product_code: effective_product_code,
2721 version: effective_query.version.clone(),
2722 source: effective_query.source.clone(),
2723 count: if request.all_versions { None } else { Some(100) },
2724 exact: effective_query.exact,
2725 install_scope: effective_query.install_scope,
2726 upgrade_only: false,
2727 include_unknown: false,
2728 include_pinned: false,
2729 };
2730
2731 let list_result = self.list(&list_query)?;
2732 if list_result.matches.is_empty() {
2733 bail!("No installed package found matching the query");
2734 }
2735 if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2736 bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2737 }
2738
2739 Ok(if request.all_versions {
2740 list_result.matches
2741 } else {
2742 vec![list_result.matches[0].clone()]
2743 })
2744 }
2745
2746 fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2747 Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2748 }
2749
2750 fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2751 let resolved = resolve_manifest_path(manifest_path)?;
2752 let bytes =
2753 fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2754 parse_yaml_manifest_bundle(&bytes)
2755 }
2756
2757 fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2758 self.find_single_match_with_semantics(query, SearchSemantics::Single)
2759 }
2760
2761 fn find_single_match_for_show(
2762 &mut self,
2763 query: &PackageQuery,
2764 semantics: SearchSemantics,
2765 ) -> Result<(LocatedMatch, Vec<String>)> {
2766 let located = self.search_located(query, semantics)?;
2767 if self.should_retry_show_across_system_winget_sources(query, &located) {
2768 let mut fallback_query = query.clone();
2769 fallback_query.source = None;
2770 return single_match_from_located(self.search_located(&fallback_query, semantics)?, false);
2771 }
2772
2773 single_match_from_located(located, query.source.is_some())
2774 }
2775
2776 fn should_retry_show_across_system_winget_sources(
2777 &self,
2778 query: &PackageQuery,
2779 located: &SearchLocatedResult,
2780 ) -> bool {
2781 should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located)
2782 }
2783
2784 fn find_single_match_with_semantics(
2785 &mut self,
2786 query: &PackageQuery,
2787 semantics: SearchSemantics,
2788 ) -> Result<(LocatedMatch, Vec<String>)> {
2789 let located = self.search_located(query, semantics)?;
2790 single_match_from_located(located, query.source.is_some())
2791 }
2792
2793 fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result<SearchLocatedResult> {
2794 let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2795 let mut matches = Vec::new();
2796 let mut warnings = Vec::new();
2797 let mut failures = Vec::new();
2798 let mut truncated = false;
2799
2800 for index in indexes {
2801 match self.search_source(index, query, semantics) {
2802 Ok(mut source_matches) => {
2803 truncated |= source_matches.truncated;
2804 matches.append(&mut source_matches.matches);
2805 }
2806 Err(error) => {
2807 let source_name = self.store.sources[index].name.clone();
2808 let message = format!("{error:#}");
2809 warnings.push(format!(
2810 "Failed when searching source '{}'; results will not be included: {}",
2811 source_name, message
2812 ));
2813 failures.push(SourceSearchFailure { source_name, message });
2814 }
2815 }
2816 }
2817
2818 if semantics == SearchSemantics::Many {
2819 matches.sort_by(|left, right| {
2820 search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2821 });
2822 let limit = max_results(query);
2823 if matches.len() > limit {
2824 truncated = true;
2825 matches.truncate(limit);
2826 }
2827 }
2828
2829 Ok(SearchLocatedResult {
2830 matches,
2831 warnings,
2832 failures,
2833 truncated,
2834 })
2835 }
2836
2837 fn search_source(
2838 &mut self,
2839 source_index: usize,
2840 query: &PackageQuery,
2841 semantics: SearchSemantics,
2842 ) -> Result<SearchSourceMatches> {
2843 match self.store.sources[source_index].kind {
2844 SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2845 SourceKind::Rest => self.search_rest(source_index, query, semantics),
2846 }
2847 }
2848
2849 fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2850 match &located.locator {
2851 MatchLocator::PreIndexedV1 { package_rowid } => {
2852 let source = self.source_clone(located.source_index);
2853 let connection = self.open_preindexed_connection(located.source_index)?;
2854 let versions = query_v1_versions(&connection, *package_rowid)?;
2855 let mut keys = versions
2856 .into_iter()
2857 .map(|row| VersionKey {
2858 version: row.version,
2859 channel: row.channel,
2860 })
2861 .collect::<Vec<_>>();
2862 sort_versions_desc(&mut keys);
2863 if keys.is_empty() {
2864 bail!("no versions found for {} in {}", located.display.id, source.name);
2865 }
2866 Ok(keys)
2867 }
2868 MatchLocator::PreIndexedV2 {
2869 package_rowid,
2870 package_hash,
2871 } => {
2872 let source = self.source_clone(located.source_index);
2873 let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2874 let mut keys = entries
2875 .into_iter()
2876 .map(|entry| VersionKey {
2877 version: entry.version,
2878 channel: String::new(),
2879 })
2880 .collect::<Vec<_>>();
2881 sort_versions_desc(&mut keys);
2882 Ok(keys)
2883 }
2884 MatchLocator::Rest { versions, .. } => {
2885 let mut keys = versions.clone();
2886 sort_versions_desc(&mut keys);
2887 Ok(keys)
2888 }
2889 }
2890 }
2891
2892 fn manifest_for_match(
2893 &mut self,
2894 located: &LocatedMatch,
2895 query: &PackageQuery,
2896 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2897 self.manifest_for_match_with_refresh(located, query, true)
2898 }
2899
2900 fn manifest_for_match_with_refresh(
2901 &mut self,
2902 located: &LocatedMatch,
2903 query: &PackageQuery,
2904 allow_explicit_version_refresh: bool,
2905 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2906 match &located.locator {
2907 MatchLocator::PreIndexedV1 { package_rowid } => {
2908 let source = self.source_clone(located.source_index);
2909 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2910 let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2911 let selected = match select_v1_version(
2912 &versions,
2913 query.version.as_deref(),
2914 query.channel.as_deref(),
2915 &located.display.id,
2916 &source.name,
2917 ) {
2918 Ok(selected) => selected,
2919 Err(error)
2920 if allow_explicit_version_refresh
2921 && query.version.is_some()
2922 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2923 {
2924 let open_refresh = opened.refresh.clone();
2925 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2926 return missing_error_after_open_refresh(error, &open_refresh);
2927 }
2928 if self.has_recent_successful_preindexed_refresh_attempt(
2929 &self.preindexed_refresh_lock_key(&source),
2930 ) {
2931 return Err(error);
2932 }
2933 drop(opened);
2934 return self.refresh_preindexed_and_retry_manifest(
2935 located.source_index,
2936 &located.display,
2937 query,
2938 error,
2939 );
2940 }
2941 Err(error) => return Err(error),
2942 };
2943 let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2944 let bytes =
2945 self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2946 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2947 manifest.version = selected.version.clone();
2948 manifest.channel = selected.channel;
2949 Ok((manifest, manifest_documents, vec![bytes.path]))
2950 }
2951 MatchLocator::PreIndexedV2 {
2952 package_rowid,
2953 package_hash,
2954 } => {
2955 let source = self.source_clone(located.source_index);
2956 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2957 let (entries, version_data_file) =
2958 self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2959 let selected = match select_v2_version(
2960 &entries,
2961 query.version.as_deref(),
2962 query.channel.as_deref(),
2963 &located.display.id,
2964 &source.name,
2965 ) {
2966 Ok(selected) => selected,
2967 Err(error)
2968 if allow_explicit_version_refresh
2969 && query.version.is_some()
2970 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2971 {
2972 let open_refresh = opened.refresh.clone();
2973 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2974 return missing_error_after_open_refresh(error, &open_refresh);
2975 }
2976 if self.has_recent_successful_preindexed_refresh_attempt(
2977 &self.preindexed_refresh_lock_key(&source),
2978 ) {
2979 return Err(error);
2980 }
2981 drop(opened);
2982 return self.refresh_preindexed_and_retry_manifest(
2983 located.source_index,
2984 &located.display,
2985 query,
2986 error,
2987 );
2988 }
2989 Err(error) => return Err(error),
2990 };
2991 let manifest_bytes = self.get_cached_source_file(
2992 "V2_M",
2993 &source,
2994 &selected.manifest_relative_path,
2995 Some(selected.manifest_hash.as_str()),
2996 )?;
2997 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2998 manifest.version = selected.version;
2999 Ok((
3000 manifest,
3001 manifest_documents,
3002 vec![version_data_file, manifest_bytes.path],
3003 ))
3004 }
3005 MatchLocator::Rest { package_id, versions } => {
3006 let source = self.source_clone(located.source_index);
3007 let selected = select_rest_version(
3008 versions,
3009 query.version.as_deref(),
3010 query.channel.as_deref(),
3011 &located.display.id,
3012 &source.name,
3013 )?;
3014 let (bytes, cache_path) =
3015 self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
3016 let (manifest, manifest_documents) =
3017 parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
3018 Ok((manifest, manifest_documents, vec![cache_path]))
3019 }
3020 }
3021 }
3022
3023 fn refresh_preindexed_and_retry_manifest(
3024 &mut self,
3025 source_index: usize,
3026 previous_display: &SearchMatch,
3027 query: &PackageQuery,
3028 missing_error: anyhow::Error,
3029 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
3030 self.refresh_preindexed_source(source_index, true)
3031 .with_context(|| format!("{missing_error} The source refresh also failed"))?;
3032 let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
3033 self.manifest_for_match_with_refresh(&refreshed, query, false)
3034 }
3035
3036 fn find_refreshed_preindexed_match(
3037 &mut self,
3038 source_index: usize,
3039 previous_display: &SearchMatch,
3040 query: &PackageQuery,
3041 ) -> Result<LocatedMatch> {
3042 let source_name = self.store.sources[source_index].name.clone();
3043 let mut source_query = query.clone();
3044 source_query.source = Some(source_name.clone());
3045 let matches = self
3046 .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
3047 .matches;
3048 if let Some(found) = matches
3049 .iter()
3050 .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
3051 {
3052 return Ok(found.clone());
3053 }
3054 if matches.len() == 1 {
3055 return Ok(matches.into_iter().next().expect("single match"));
3056 }
3057 bail!(
3058 "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
3059 previous_display.id,
3060 source_name
3061 )
3062 }
3063
3064 fn search_preindexed(
3065 &mut self,
3066 source_index: usize,
3067 query: &PackageQuery,
3068 semantics: SearchSemantics,
3069 ) -> Result<SearchSourceMatches> {
3070 let connection = self.open_preindexed_connection(source_index)?;
3071 let source = self.source_clone(source_index);
3072
3073 match query_v2_matches(&connection, query, semantics) {
3074 Ok((rows, truncated)) => Ok(SearchSourceMatches {
3075 truncated,
3076 matches: rows
3077 .into_iter()
3078 .map(|row| LocatedMatch {
3079 display: SearchMatch {
3080 source_name: source.name.clone(),
3081 source_kind: source.kind,
3082 id: row.id.clone(),
3083 name: row.name.clone(),
3084 moniker: row.moniker.clone(),
3085 version: Some(row.version.clone()),
3086 channel: None,
3087 match_criteria: row.match_criteria.clone(),
3088 },
3089 source_index,
3090 locator: MatchLocator::PreIndexedV2 {
3091 package_rowid: row.package_rowid,
3092 package_hash: row.package_hash,
3093 },
3094 })
3095 .collect(),
3096 }),
3097 Err(error) if can_fallback_to_v1(&error) => {
3098 let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
3099 let grouped = group_v1_rows(rows);
3100
3101 Ok(SearchSourceMatches {
3102 truncated,
3103 matches: grouped
3104 .into_iter()
3105 .map(|row| LocatedMatch {
3106 display: SearchMatch {
3107 source_name: source.name.clone(),
3108 source_kind: source.kind,
3109 id: row.id.clone(),
3110 name: row.name.clone(),
3111 moniker: row.moniker.clone(),
3112 version: Some(row.version.clone()),
3113 channel: Some(row.channel.clone()),
3114 match_criteria: row.match_criteria.clone(),
3115 },
3116 source_index,
3117 locator: MatchLocator::PreIndexedV1 {
3118 package_rowid: row.package_rowid,
3119 },
3120 })
3121 .collect(),
3122 })
3123 }
3124 Err(error) => Err(error),
3125 }
3126 }
3127
3128 fn search_rest(
3129 &mut self,
3130 source_index: usize,
3131 query: &PackageQuery,
3132 semantics: SearchSemantics,
3133 ) -> Result<SearchSourceMatches> {
3134 let source = self.source_clone(source_index);
3135 let info = self.load_rest_information(source_index)?;
3136 let contract = choose_contract(&info.server_supported_versions)
3137 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3138
3139 let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
3140 let body = build_rest_search_body(query, &info, semantics)?;
3141 let response = self
3142 .client
3143 .post(url)
3144 .header("Version", contract)
3145 .header(CONTENT_TYPE, "application/json")
3146 .body(serde_json::to_vec(&body).context("failed to serialize REST search request")?)
3147 .send()
3148 .context("REST search request failed")?
3149 .error_for_status()
3150 .context("REST search request returned an error")?;
3151 let json =
3152 serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST search response")?)
3153 .context("failed to parse REST search response")?;
3154 let data = json
3155 .get("Data")
3156 .and_then(JsonValue::as_array)
3157 .cloned()
3158 .unwrap_or_default();
3159 let max_results = source_fetch_results(query, semantics);
3160
3161 let mut results = Vec::new();
3162 for item in data {
3163 let package_id = json_string(&item, "PackageIdentifier")
3164 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3165 let package_name =
3166 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3167 let mut versions = parse_rest_versions(&item)?;
3168 sort_versions_desc(&mut versions);
3169 let latest = versions
3170 .first()
3171 .cloned()
3172 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3173
3174 results.push(LocatedMatch {
3175 display: SearchMatch {
3176 source_name: source.name.clone(),
3177 source_kind: source.kind,
3178 id: package_id.clone(),
3179 name: package_name,
3180 moniker: json_string(&item, "Moniker"),
3181 version: Some(latest.version.clone()),
3182 channel: if latest.channel.is_empty() {
3183 None
3184 } else {
3185 Some(latest.channel.clone())
3186 },
3187 match_criteria: rest_match_criteria(&item, query, semantics),
3188 },
3189 source_index,
3190 locator: MatchLocator::Rest { package_id, versions },
3191 });
3192 }
3193
3194 Ok(SearchSourceMatches {
3195 truncated: results.len() >= max_results,
3196 matches: results,
3197 })
3198 }
3199
3200 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3201 Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3202 }
3203
3204 fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3205 let source = self.source_clone(source_index);
3206 let index_path = self.preindexed_index_path(&source);
3207 let mut refresh = PreindexedRefreshOutcome::default();
3208 if !index_path.exists() {
3209 let _ = self.refresh_preindexed_source(source_index, true)?;
3210 refresh.attempted = true;
3211 } else if self.is_preindexed_index_stale(&source, &index_path) {
3212 let lock_key = self.preindexed_refresh_lock_key(&source);
3213 if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3214 refresh.attempted = true;
3215 refresh.reused_recent_attempt = true;
3216 } else {
3217 refresh.attempted = true;
3218 if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3219 refresh.error = Some(format!("{error:#}"));
3220 }
3221 }
3222 }
3223
3224 Ok(PreindexedOpenConnection {
3225 connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3226 refresh,
3227 })
3228 }
3229
3230 fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3231 let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3232 return false;
3233 };
3234
3235 let index_modified = || {
3236 fs::metadata(index_path)
3237 .ok()
3238 .and_then(|metadata| metadata.modified().ok())
3239 .map(DateTime::<Utc>::from)
3240 };
3241
3242 let last_update = match (source.last_update, index_modified()) {
3243 (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3244 (Some(source_last_update), None) => Some(source_last_update),
3245 (None, Some(index_last_write)) => Some(index_last_write),
3246 (None, None) => None,
3247 };
3248
3249 match last_update {
3250 Some(last_update) => Utc::now() - last_update >= interval,
3251 None => true,
3252 }
3253 }
3254
3255 fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3256 format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3257 }
3258
3259 fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3260 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3261 Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3262 })
3263 }
3264
3265 fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3266 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3267 attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3268 })
3269 }
3270
3271 fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3272 self.preindexed_refresh_attempts.insert(
3273 lock_key,
3274 PreindexedRefreshAttempt {
3275 attempted_at: Utc::now(),
3276 succeeded,
3277 },
3278 );
3279 }
3280
3281 fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3282 {
3283 let locks = preindexed_refresh_locks()
3284 .read()
3285 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3286 if let Some(existing) = locks.get(lock_key) {
3287 return Ok(Arc::clone(existing));
3288 }
3289 }
3290
3291 let mut locks = preindexed_refresh_locks()
3292 .write()
3293 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3294 Ok(Arc::clone(
3295 locks
3296 .entry(lock_key.to_owned())
3297 .or_insert_with(|| Arc::new(Mutex::new(()))),
3298 ))
3299 }
3300
3301 fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3302 let source = self.source_clone(source_index);
3303 let index_path = self.preindexed_index_path(&source);
3304 let lock_key = self.preindexed_refresh_lock_key(&source);
3305 let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3306 let _guard = refresh_lock
3307 .lock()
3308 .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3309
3310 if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3311 return Ok("source already refreshed".to_owned());
3312 }
3313
3314 let result = self.update_preindexed(source_index);
3315 self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3316 let detail = result?;
3317 self.save_store()?;
3318 Ok(detail)
3319 }
3320
3321 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3322 let path_text = path.to_string_lossy().into_owned();
3323 let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3324 .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3325 database
3326 .connect()
3327 .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3328 }
3329
3330 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3331 let source_snapshot = self.store.sources[source_index].clone();
3332 let state_dir = self.source_state_dir(&source_snapshot);
3333 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3334 let index_path = self.preindexed_index_path(&source_snapshot);
3335 let has_local_index = index_path.exists();
3336
3337 let mut last_error = None;
3338 for candidate in PREINDEXED_CANDIDATES {
3339 let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3340 if has_local_index
3341 && let Some(detail) =
3342 self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3343 {
3344 return Ok(detail);
3345 }
3346
3347 let mut request = self.client.get(&url);
3348 if has_local_index {
3349 if let Some(etag) = &source_snapshot.etag
3350 && let Ok(value) = HeaderValue::from_str(etag)
3351 {
3352 request = request.header(IF_NONE_MATCH, value);
3353 }
3354 if let Some(last_modified) = &source_snapshot.last_modified
3355 && let Ok(value) = HeaderValue::from_str(last_modified)
3356 {
3357 request = request.header(IF_MODIFIED_SINCE, value);
3358 }
3359 }
3360
3361 match request.send() {
3362 Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3363 let source = &mut self.store.sources[source_index];
3364 update_preindexed_http_validators(source, response.headers());
3365 source.last_update = Some(Utc::now());
3366 return Ok(format!("already up to date from {}", candidate));
3367 }
3368 Ok(response) if response.status().is_success() => {
3369 let header_version = response
3370 .headers()
3371 .get("x-ms-meta-sourceversion")
3372 .and_then(|value| value.to_str().ok())
3373 .map(str::to_owned);
3374 let headers = response.headers().clone();
3375 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3376 let payload = bytes.to_vec();
3377 let index_bytes = extract_zip_member(&payload, "Public/index.db")
3378 .context("preindexed package did not contain Public/index.db")?;
3379
3380 fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3381 .context("failed to persist source package")?;
3382 let temp_index_path = state_dir.join(format!(
3383 "index.{}.tmp",
3384 Utc::now().timestamp_nanos_opt().unwrap_or_default()
3385 ));
3386 fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3387 if let Err(error) = replace_file(&temp_index_path, &index_path) {
3388 let _ = fs::remove_file(&temp_index_path);
3389 return Err(error).context("failed to persist source index");
3390 }
3391 remove_sqlite_sidecars(&index_path);
3396 let source = &mut self.store.sources[source_index];
3397 source.last_update = Some(Utc::now());
3398 source.source_version = header_version;
3399 update_preindexed_http_validators(source, &headers);
3400 return Ok(format!("downloaded {}", candidate));
3401 }
3402 Ok(response) => {
3403 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3404 }
3405 Err(error) => {
3406 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3407 }
3408 }
3409 }
3410
3411 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3412 }
3413
3414 fn try_skip_unchanged_preindexed_by_source_version(
3415 &mut self,
3416 source_index: usize,
3417 url: &str,
3418 candidate: &str,
3419 ) -> Option<String> {
3420 let current_version = self.store.sources[source_index].source_version.as_deref()?;
3421 let response = self.client.head(url).send().ok()?;
3422 if !response.status().is_success() {
3423 return None;
3424 }
3425
3426 let header_version = response
3427 .headers()
3428 .get("x-ms-meta-sourceversion")
3429 .and_then(|value| value.to_str().ok())?;
3430 if !header_version.eq_ignore_ascii_case(current_version) {
3431 return None;
3432 }
3433
3434 let source = &mut self.store.sources[source_index];
3435 update_preindexed_http_validators(source, response.headers());
3436 source.last_update = Some(Utc::now());
3437 Some(format!("already up to date from {} (v{})", candidate, header_version))
3438 }
3439
3440 fn update_rest(&mut self, source_index: usize) -> Result<String> {
3441 let info = self.fetch_rest_information(source_index, true)?;
3442 let source = &mut self.store.sources[source_index];
3443 if !info.source_identifier.is_empty() {
3444 source.identifier = info.source_identifier.clone();
3445 }
3446 source.last_update = Some(Utc::now());
3447 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3448 Ok("refreshed information cache".to_owned())
3449 }
3450
3451 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3452 let source = self.source_clone(source_index);
3453 let cache_path = self.rest_information_cache_path(&source);
3454
3455 if cache_path.exists() {
3456 let cache = serde_json::from_slice::<RestInfoCache>(
3457 &fs::read(&cache_path).context("failed to read REST information cache")?,
3458 )
3459 .context("failed to parse REST information cache")?;
3460 if cache.expires_at > Utc::now() {
3461 return Ok(cache.value);
3462 }
3463 }
3464
3465 self.fetch_rest_information(source_index, true)
3466 }
3467
3468 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3469 let source = self.source_clone(source_index);
3470 let url = format!("{}/information", source.arg.trim_end_matches('/'));
3471 let response = self
3472 .client
3473 .get(url)
3474 .send()
3475 .context("REST information request failed")?
3476 .error_for_status()
3477 .context("REST information request returned an error")?;
3478
3479 let max_age = cache_control_max_age(&response);
3480 let json =
3481 serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST information response")?)
3482 .context("failed to parse REST information response")?;
3483 let data = json
3484 .get("Data")
3485 .cloned()
3486 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3487 let info = serde_json::from_value::<RestInformation>(data)
3488 .context("failed to deserialize REST information payload")?;
3489
3490 if persist {
3491 let cache = RestInfoCache {
3492 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3493 value: info.clone(),
3494 };
3495 write_json(self.rest_information_cache_path(&source), &cache)?;
3496 }
3497
3498 Ok(info)
3499 }
3500
3501 fn load_v2_version_data(
3502 &self,
3503 source: &SourceRecord,
3504 package_rowid: i64,
3505 package_hash: &str,
3506 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3507 let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3508 .context("failed to reopen preindexed index for V2 version data")?;
3509 let package_hash = package_hash.to_ascii_lowercase();
3510 let package_id = query_optional_value(
3511 &connection,
3512 "SELECT id FROM packages WHERE rowid = ?1",
3513 vec![SqlValue::Integer(package_rowid)],
3514 |row| row_string(row, 0),
3515 )?
3516 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3517 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3518 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3519 let yaml = decompress_mszyml(&file.bytes)?;
3520 let document =
3521 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3522 Ok((document.versions, file.path))
3523 }
3524
3525 fn get_cached_source_file(
3526 &self,
3527 bucket: &str,
3528 source: &SourceRecord,
3529 relative_path: &str,
3530 expected_hash: Option<&str>,
3531 ) -> Result<CachedBytes> {
3532 let normalized_relative = relative_path.replace('\\', "/");
3533 let cache_path =
3534 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3535
3536 if cache_path.exists() {
3537 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3538 if hash_matches(expected_hash, &cached) {
3539 return Ok(CachedBytes {
3540 path: cache_path,
3541 bytes: cached,
3542 });
3543 }
3544 }
3545
3546 let url = format!(
3547 "{}/{}",
3548 source.arg.trim_end_matches('/'),
3549 normalized_relative.trim_start_matches('/')
3550 );
3551 let response = self
3552 .client
3553 .get(url)
3554 .send()
3555 .context("failed to fetch source file")?
3556 .error_for_status()
3557 .context("source file request returned an error")?;
3558 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3559
3560 if let Some(hash) = expected_hash {
3561 verify_hash(hash, &bytes)?;
3562 }
3563
3564 if let Some(parent) = cache_path.parent() {
3565 fs::create_dir_all(parent).context("failed to create cache directory")?;
3566 }
3567 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3568
3569 Ok(CachedBytes {
3570 path: cache_path,
3571 bytes,
3572 })
3573 }
3574
3575 fn get_or_fetch_rest_manifest(
3576 &mut self,
3577 source: &SourceRecord,
3578 package_id: &str,
3579 version: &str,
3580 channel: &str,
3581 ) -> Result<(Vec<u8>, PathBuf)> {
3582 let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3583 if cache_path.exists() {
3584 return Ok((
3585 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3586 cache_path,
3587 ));
3588 }
3589
3590 let info = self.load_rest_information_by_name(&source.name)?;
3591 let contract = choose_contract(&info.server_supported_versions)
3592 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3593 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3594
3595 let mut params = vec![("Version", version.to_owned())];
3596 if !channel.is_empty() {
3597 params.push(("Channel", channel.to_owned()));
3598 }
3599 if info
3600 .required_query_parameters
3601 .iter()
3602 .any(|value| value.eq_ignore_ascii_case("Market"))
3603 {
3604 params.push(("Market", default_market()));
3605 }
3606
3607 let response = self
3608 .client
3609 .get(url)
3610 .header("Version", contract)
3611 .query(¶ms)
3612 .send()
3613 .context("REST manifest request failed")?
3614 .error_for_status()
3615 .context("REST manifest request returned an error")?;
3616 let bytes = response
3617 .bytes()
3618 .context("failed to read REST manifest response")?
3619 .to_vec();
3620
3621 if let Some(parent) = cache_path.parent() {
3622 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3623 }
3624 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3625
3626 Ok((bytes, cache_path))
3627 }
3628
3629 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3630 let index = self
3631 .store
3632 .sources
3633 .iter()
3634 .position(|source| source.name.eq_ignore_ascii_case(name))
3635 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3636 self.load_rest_information(index)
3637 }
3638
3639 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3640 if let Some(name) = source_name {
3641 if let Some(index) = self
3642 .store
3643 .sources
3644 .iter()
3645 .position(|source| source.name.eq_ignore_ascii_case(name))
3646 {
3647 return Ok(vec![index]);
3648 }
3649
3650 let index = self
3651 .store
3652 .sources
3653 .iter()
3654 .position(|source| source.identifier.eq_ignore_ascii_case(name))
3655 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3656 return Ok(vec![index]);
3657 }
3658
3659 let mut indexes: Vec<_> = self
3660 .store
3661 .sources
3662 .iter()
3663 .enumerate()
3664 .filter(|(_, source)| !source.explicit)
3665 .collect();
3666 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3667 right_source
3668 .priority
3669 .cmp(&left_source.priority)
3670 .then_with(|| left_index.cmp(right_index))
3671 });
3672 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3673 }
3674
3675 fn source_clone(&self, index: usize) -> SourceRecord {
3676 self.store.sources[index].clone()
3677 }
3678
3679 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3680 self.store = match self.source_mode {
3681 EffectiveSourceMode::Private => self.store.clone(),
3682 EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3683 EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3684 };
3685 Ok(())
3686 }
3687
3688 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3689 self.refresh_system_winget_sources()?;
3690 let selected_names: Vec<_> = if source_name.is_none() {
3691 self.store.sources.iter().map(|source| source.name.clone()).collect()
3692 } else {
3693 self.resolve_source_indexes(source_name)?
3694 .into_iter()
3695 .map(|index| self.store.sources[index].name.clone())
3696 .collect()
3697 };
3698
3699 let detail = system_winget_update_sources(source_name)?;
3700 self.refresh_system_winget_sources()?;
3701
3702 Ok(selected_names
3703 .into_iter()
3704 .filter_map(|name| {
3705 self.store
3706 .sources
3707 .iter()
3708 .find(|source| source.name.eq_ignore_ascii_case(&name))
3709 .map(|source| SourceUpdateResult {
3710 name: source.name.clone(),
3711 kind: source.kind,
3712 detail: detail.clone(),
3713 })
3714 })
3715 .collect())
3716 }
3717}
3718
3719#[derive(Debug)]
3720struct CachedBytes {
3721 path: PathBuf,
3722 bytes: Vec<u8>,
3723}
3724
3725#[derive(Debug, Clone)]
3726struct V1SearchRow {
3727 manifest_rowid: i64,
3728 package_rowid: i64,
3729 version: String,
3730 channel: String,
3731 id: String,
3732 name: String,
3733 moniker: Option<String>,
3734 match_criteria: Option<String>,
3735}
3736
3737#[derive(Debug, Clone)]
3738struct V1VersionRow {
3739 version: String,
3740 channel: String,
3741 pathpart_id: i64,
3742 manifest_hash: Option<String>,
3743}
3744
3745#[derive(Debug, Clone)]
3746struct V2SearchRow {
3747 package_rowid: i64,
3748 package_hash: String,
3749 id: String,
3750 name: String,
3751 moniker: Option<String>,
3752 version: String,
3753 match_criteria: Option<String>,
3754}
3755
3756fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3757 query.query.is_some()
3758 || query.id.is_some()
3759 || query.name.is_some()
3760 || query.moniker.is_some()
3761 || query.tag.is_some()
3762 || query.command.is_some()
3763 || query.source.is_some()
3764}
3765
3766fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3767 PackageQuery {
3768 query: query.query.clone(),
3769 id: query.id.clone(),
3770 name: query.name.clone(),
3771 moniker: query.moniker.clone(),
3772 tag: query.tag.clone(),
3773 command: query.command.clone(),
3774 source: query.source.clone(),
3775 count: Some(LIST_LOOKUP_MAX_RESULTS),
3776 exact: query.exact,
3777 version: None,
3778 channel: None,
3779 locale: None,
3780 installer_type: None,
3781 installer_architecture: None,
3782 platform: None,
3783 os_version: None,
3784 install_scope: query.install_scope.clone(),
3785 }
3786}
3787
3788fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3789 query.query.is_some() || query.id.is_some() || query.name.is_some()
3790}
3791
3792fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3793 if is_greater_than_version_marker(&package.installed_version) {
3794 return false;
3795 }
3796 package
3797 .correlated
3798 .as_ref()
3799 .and_then(|candidate| candidate.version.as_deref())
3800 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3801}
3802
3803fn is_greater_than_version_marker(version: &str) -> bool {
3804 version.trim_start().starts_with('>')
3805}
3806
3807fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3808 package.installed_version.eq_ignore_ascii_case("Unknown")
3809}
3810
3811fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3812 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3815 return false;
3816 }
3817 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3820 return false;
3821 }
3822 installed_package_has_upgrade(package)
3823 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3824}
3825
3826fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3832 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3833 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3834
3835 for package in packages {
3836 match package.correlated.as_ref() {
3837 Some(candidate) => {
3838 let key = (
3839 candidate.id.to_ascii_lowercase(),
3840 candidate.source_name.to_ascii_lowercase(),
3841 );
3842 let keep_new = match by_id.get(&key) {
3843 Some(existing) => {
3844 match (
3850 package.installed_version_canonical,
3851 existing.installed_version_canonical,
3852 ) {
3853 (true, false) => true,
3854 (false, true) => false,
3855 _ => {
3856 compare_version(&package.installed_version, &existing.installed_version)
3857 == Ordering::Greater
3858 }
3859 }
3860 }
3861 None => true,
3862 };
3863 if keep_new {
3864 by_id.insert(key, package);
3865 }
3866 }
3867 None => uncorrelated.push(package),
3868 }
3869 }
3870
3871 let mut result = uncorrelated;
3872 result.extend(by_id.into_values());
3873 result
3874}
3875
3876fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3877 let mut source_specific = None;
3878 let mut source_agnostic = None;
3879
3880 for pin in pins {
3881 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3882 continue;
3883 }
3884
3885 if pin.source_id.is_empty() {
3886 if source_agnostic.is_none() {
3887 source_agnostic = Some(pin);
3888 }
3889 } else if item
3890 .source_name
3891 .as_deref()
3892 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3893 {
3894 source_specific = Some(pin);
3895 break;
3896 }
3897 }
3898
3899 source_specific.or(source_agnostic)
3900}
3901
3902fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3903 let Some(available_version) = item.available_version.as_deref() else {
3904 return false;
3905 };
3906 let Some(pin) = find_applicable_pin(item, pins) else {
3907 return false;
3908 };
3909
3910 match pin.pin_type {
3911 PinType::Blocking => true,
3912 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3913 }
3914}
3915
3916fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3917 if pattern.trim().is_empty() || pattern == "*" {
3918 return true;
3919 }
3920
3921 if !pattern.contains('*') {
3922 return version.eq_ignore_ascii_case(pattern);
3923 }
3924
3925 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3926 let prefix = &pattern[..pattern.len() - 1];
3927 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3928 }
3929
3930 version.eq_ignore_ascii_case(pattern)
3931}
3932
3933fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3934 let available_version = package.correlated.as_ref().and_then(|candidate| {
3935 candidate.version.as_ref().and_then(|candidate_version| {
3936 if !is_greater_than_version_marker(&package.installed_version)
3937 && (installed_package_has_unknown_version(&package)
3938 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3939 {
3940 Some(candidate_version.clone())
3941 } else {
3942 None
3943 }
3944 })
3945 });
3946 let mut source_name = package
3947 .correlated
3948 .as_ref()
3949 .map(|candidate| candidate.source_name.clone())
3950 .filter(|value| !value.is_empty());
3951 let mut id = package
3952 .correlated
3953 .as_ref()
3954 .map(|candidate| candidate.id.clone())
3955 .unwrap_or_else(|| package.local_id.clone());
3956
3957 if source_name.is_none()
3958 && let Some(package_id) = package
3959 .winget_package_identifier
3960 .as_deref()
3961 .filter(|value| !value.trim().is_empty())
3962 {
3963 id = package_id.to_owned();
3964 source_name = package
3965 .winget_source_identifier
3966 .as_deref()
3967 .filter(|value| !value.trim().is_empty())
3968 .map(|identifier| source_name_from_identifier(identifier, sources));
3969 }
3970
3971 if source_name.is_none()
3972 && let Some((local_package_id, local_source_name)) =
3973 winget_package_identity_from_local_id(&package.local_id, sources)
3974 {
3975 id = local_package_id;
3976 source_name = Some(local_source_name);
3977 }
3978
3979 ListMatch {
3980 name: package.name,
3981 id,
3982 local_id: package.local_id,
3983 installed_version: package.installed_version,
3984 available_version,
3985 source_name,
3986 publisher: package.publisher,
3987 scope: package.scope,
3988 installer_category: package.installer_category,
3989 install_location: package.install_location,
3990 package_family_names: package.package_family_names,
3991 product_codes: package.product_codes,
3992 upgrade_codes: package.upgrade_codes,
3993 }
3994}
3995
3996fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
3997 let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
3998 for (index, item) in matches.iter().enumerate() {
3999 let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
4000 continue;
4001 };
4002 grouped
4003 .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4004 .or_default()
4005 .push(index);
4006 }
4007
4008 let mut keep = BTreeSet::new();
4009 for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
4010 let Some(best) = indexes
4011 .iter()
4012 .map(|index| matches[*index].installed_version.as_str())
4013 .max_by(|left, right| compare_version(left, right))
4014 else {
4015 continue;
4016 };
4017 for index in indexes {
4018 if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
4019 keep.insert(*index);
4020 }
4021 }
4022 }
4023
4024 matches
4025 .into_iter()
4026 .enumerate()
4027 .map(|(index, mut item)| {
4028 if item.available_version.is_some() && !keep.contains(&index) {
4029 let is_duplicate = item
4030 .source_name
4031 .as_ref()
4032 .map(|source_name| {
4033 grouped
4034 .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4035 .is_some_and(|indexes| indexes.len() > 1)
4036 })
4037 .unwrap_or(false);
4038 if is_duplicate {
4039 item.available_version = None;
4040 }
4041 }
4042 item
4043 })
4044 .collect()
4045}
4046
4047fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4048 sources
4049 .iter()
4050 .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4051 .map(|source| source.name.clone())
4052 .unwrap_or_else(|| identifier.to_owned())
4053}
4054
4055fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4056 let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4057 for source in sources {
4058 let source_suffix = format!("_{}", source.identifier);
4059 let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4060 continue;
4061 };
4062 if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4063 continue;
4064 }
4065 if source_identifier_start_index <= package_id_start_index {
4066 return None;
4067 }
4068 let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4069 if package_id.trim().is_empty() {
4070 return None;
4071 }
4072 return Some((package_id, source.name.clone()));
4073 }
4074 None
4075}
4076
4077fn list_sort_weight(package: &InstalledPackage) -> usize {
4078 if package.local_id.starts_with("ARP\\") {
4079 0
4080 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4081 1
4082 } else {
4083 2
4084 }
4085}
4086
4087fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4088 let correlated = package.correlated.as_ref();
4089
4090 if let Some(value) = &query.id {
4091 let local_match = matches_text(&package.local_id, value, query.exact);
4092 let correlated_match = correlated
4093 .map(|candidate| matches_text(&candidate.id, value, query.exact))
4094 .unwrap_or(false);
4095 if !local_match && !correlated_match {
4096 return false;
4097 }
4098 }
4099
4100 if let Some(value) = &query.name
4101 && !matches_text(&package.name, value, query.exact)
4102 {
4103 return false;
4104 }
4105
4106 if let Some(value) = &query.product_code
4107 && !package
4108 .product_codes
4109 .iter()
4110 .any(|code| code.eq_ignore_ascii_case(value))
4111 {
4112 return false;
4113 }
4114
4115 if let Some(value) = &query.version
4116 && !package.installed_version.eq_ignore_ascii_case(value)
4117 {
4118 return false;
4119 }
4120
4121 if let Some(value) = &query.query {
4122 let local_match =
4123 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4124 let correlated_match = correlated
4125 .map(|candidate| {
4126 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4127 })
4128 .unwrap_or(false);
4129 if !local_match && !correlated_match {
4130 return false;
4131 }
4132 }
4133
4134 if let Some(source) = &query.source
4135 && correlated
4136 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4137 .unwrap_or(true)
4138 {
4139 return false;
4140 }
4141
4142 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4143 return false;
4144 }
4145
4146 true
4147}
4148
4149fn correlate_installed_package(
4150 package: &InstalledPackage,
4151 candidates: &[SearchMatch],
4152 allow_loose_name_match: bool,
4153) -> Option<SearchMatch> {
4154 if package.local_id.starts_with("MSIX\\") {
4162 return None;
4163 }
4164
4165 let installed_name = normalize_correlation_name(&package.name);
4166 let installed_name_lower = package.name.to_ascii_lowercase();
4167 let candidate_names = correlation_name_candidates(&package.name);
4168
4169 let mut scored: Vec<(i32, SearchMatch)> = candidates
4170 .iter()
4171 .filter_map(|candidate| {
4172 let candidate_name = normalize_correlation_name(&candidate.name);
4173 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4174 1000
4175 } else if candidate_names.iter().any(|name| {
4176 let normalized = normalize_correlation_name(name);
4177 normalized == candidate_name
4178 }) {
4179 900
4180 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4181 700
4182 } else {
4183 0
4184 };
4185
4186 if base_score == 0 {
4187 return None;
4188 }
4189
4190 let candidate_lower = candidate.name.to_ascii_lowercase();
4203 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4204 100
4205 } else if installed_name_lower.contains(&candidate_lower) {
4206 50
4207 } else {
4208 0
4209 };
4210
4211 Some((base_score + prefix_bonus, candidate.clone()))
4212 })
4213 .collect();
4214
4215 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4216 let mut iter = scored.into_iter();
4217 let (top_score, top_match) = iter.next()?;
4218
4219 if let Some((next_score, _)) = iter.next()
4227 && next_score == top_score
4228 {
4229 return None;
4230 }
4231 Some(top_match)
4232}
4233
4234fn correlation_name_candidates(name: &str) -> Vec<String> {
4235 let mut candidates = Vec::new();
4236 candidates.push(name.trim().to_owned());
4237
4238 let mut trimmed = name.trim().to_owned();
4239 if let Some(index) = trimmed.find(" (") {
4240 trimmed.truncate(index);
4241 }
4242
4243 let mut words = Vec::new();
4244 for token in trimmed.split_whitespace() {
4245 let lower = token
4246 .trim_matches(|ch: char| ch == '(' || ch == ')')
4247 .to_ascii_lowercase();
4248 if !words.is_empty()
4249 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4250 {
4251 break;
4252 }
4253 words.push(token);
4254 }
4255
4256 if !words.is_empty() {
4257 candidates.push(words.join(" "));
4258 }
4259
4260 candidates.sort();
4261 candidates.dedup();
4262 candidates
4263}
4264
4265fn normalize_correlation_name(value: &str) -> String {
4266 value
4267 .chars()
4268 .filter(|ch| ch.is_ascii_alphanumeric())
4269 .flat_map(|ch| ch.to_lowercase())
4270 .collect()
4271}
4272
4273struct V2PackageMetadata {
4274 rowid: i64,
4275 id: String,
4276 name: String,
4277 moniker: Option<String>,
4278 latest_version: String,
4279 package_hash: String,
4280}
4281
4282fn lookup_identity_match_v2(
4288 connection: &Connection,
4289 package: &InstalledPackage,
4290) -> Result<Option<(i64, &'static str)>> {
4291 for pfn in &package.package_family_names {
4292 if let Some(rowid) = query_optional_value(
4293 connection,
4294 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4295 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4296 |row| row_i64(row, 0),
4297 )? {
4298 return Ok(Some((rowid, "PackageFamilyName")));
4299 }
4300 }
4301 for code in &package.upgrade_codes {
4310 if let Some(rowid) = query_optional_value(
4311 connection,
4312 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4313 vec![SqlValue::Text(code.to_ascii_lowercase())],
4314 |row| row_i64(row, 0),
4315 )? {
4316 return Ok(Some((rowid, "UpgradeCode")));
4317 }
4318 }
4319 for code in &package.product_codes {
4320 if let Some(rowid) = query_optional_value(
4321 connection,
4322 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4323 vec![SqlValue::Text(code.to_ascii_lowercase())],
4324 |row| row_i64(row, 0),
4325 )? {
4326 return Ok(Some((rowid, "ProductCode")));
4327 }
4328 }
4329 Ok(None)
4330}
4331
4332fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4333 Ok(table_exists(connection, "manifest")?
4334 && table_exists(connection, "ids")?
4335 && table_exists(connection, "names")?
4336 && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4337 || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4338 || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4339}
4340
4341fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4342 query_optional_value(
4343 connection,
4344 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4345 vec![SqlValue::Text(name.to_owned())],
4346 |row| row_i64(row, 0),
4347 )
4348 .map(|value| value.is_some())
4349}
4350
4351fn lookup_installed_db_identity_match(
4352 connection: &Connection,
4353 package: &InstalledPackage,
4354) -> Result<Option<(String, String, &'static str)>> {
4355 for pfn in &package.package_family_names {
4356 if let Some(hit) =
4357 lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4358 {
4359 return Ok(Some(hit));
4360 }
4361 }
4362 for code in &package.upgrade_codes {
4363 if let Some(hit) = lookup_installed_db_identity_match_value(
4364 connection,
4365 "upgradecodes",
4366 "upgradecodes_map",
4367 "upgradecode",
4368 code,
4369 "UpgradeCode",
4370 )? {
4371 return Ok(Some(hit));
4372 }
4373 }
4374 for code in &package.product_codes {
4375 if let Some(hit) = lookup_installed_db_identity_match_value(
4376 connection,
4377 "productcodes",
4378 "productcodes_map",
4379 "productcode",
4380 code,
4381 "ProductCode",
4382 )? {
4383 return Ok(Some(hit));
4384 }
4385 }
4386 Ok(None)
4387}
4388
4389fn lookup_installed_db_identity_match_value(
4390 connection: &Connection,
4391 value_table: &'static str,
4392 map_table: &'static str,
4393 value_column: &'static str,
4394 value: &str,
4395 matched_by: &'static str,
4396) -> Result<Option<(String, String, &'static str)>> {
4397 let sql = format!(
4398 "SELECT ids.id, names.name \
4399 FROM {map_table} map \
4400 JOIN {value_table} value ON map.{value_column} = value.rowid \
4401 JOIN manifest ON map.manifest = manifest.rowid \
4402 JOIN ids ON manifest.id = ids.rowid \
4403 JOIN names ON manifest.name = names.rowid \
4404 WHERE value.{value_column} = ?1 \
4405 LIMIT 1"
4406 );
4407 query_optional_value(
4408 connection,
4409 &sql,
4410 vec![SqlValue::Text(value.to_ascii_lowercase())],
4411 |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4412 )
4413}
4414
4415fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4419 query_optional_value(
4420 connection,
4421 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4422 vec![SqlValue::Text(package_id.to_owned())],
4423 |row| {
4424 Ok(V2PackageMetadata {
4425 rowid: row_i64(row, 0)?,
4426 id: row_string(row, 1)?,
4427 name: row_string(row, 2)?,
4428 moniker: row_opt_string(row, 3)?,
4429 latest_version: row_string(row, 4)?,
4430 package_hash: row_hex_string(row, 5)?,
4431 })
4432 },
4433 )
4434}
4435
4436fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4444 if !package.local_id.starts_with("MSIX\\") {
4445 return;
4446 }
4447 if !package.name.starts_with("ms-resource:") {
4448 return;
4449 }
4450 let Some(candidate) = package.correlated.as_ref() else {
4451 return;
4452 };
4453 if !candidate.name.is_empty() {
4454 package.name = candidate.name.clone();
4455 }
4456}
4457
4458fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4459 query_optional_value(
4460 connection,
4461 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4462 vec![SqlValue::Integer(package_rowid)],
4463 |row| {
4464 Ok(V2PackageMetadata {
4465 rowid: package_rowid,
4466 id: row_string(row, 0)?,
4467 name: row_string(row, 1)?,
4468 moniker: row_opt_string(row, 2)?,
4469 latest_version: row_string(row, 3)?,
4470 package_hash: row_hex_string(row, 4)?,
4471 })
4472 },
4473 )
4474}
4475
4476fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4477 query_optional_value(
4478 connection,
4479 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4480 Vec::new(),
4481 |row| row_i64(row, 0),
4482 )
4483 .ok()
4484 .flatten()
4485 .is_some()
4486}
4487
4488fn lookup_unique_normalized_identity(
4495 connection: &Connection,
4496 norm_name: &str,
4497 norm_publisher: &str,
4498) -> Result<Option<i64>> {
4499 let rows: Vec<i64> = query_rows(
4500 connection,
4501 "SELECT DISTINCT n.package FROM norm_names2 n \
4502 WHERE n.norm_name = ?1 \
4503 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4504 LIMIT 2",
4505 vec![
4506 SqlValue::Text(norm_name.to_owned()),
4507 SqlValue::Text(norm_publisher.to_owned()),
4508 ],
4509 |row| row_i64(row, 0),
4510 )?;
4511 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4512}
4513
4514fn v2_identity_tables_present(connection: &Connection) -> bool {
4515 query_optional_value(
4516 connection,
4517 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4518 Vec::new(),
4519 |row| row_i64(row, 0),
4520 )
4521 .ok()
4522 .flatten()
4523 .is_some()
4524}
4525
4526fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4533 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4534 return None;
4535 }
4536 for entry in entries {
4537 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4538 continue;
4539 };
4540 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4541 {
4542 return Some(entry.version.clone());
4543 }
4544 }
4545 None
4546}
4547
4548fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4554 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4555 return None;
4556 }
4557
4558 let latest = entries
4559 .iter()
4560 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4561 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4562 if compare_version(arp_version, &latest.version) != Ordering::Equal {
4563 return None;
4564 }
4565 let latest_min = latest.arp_min_version.as_deref()?;
4566 if compare_version(arp_version, latest_min) != Ordering::Less {
4567 return None;
4568 }
4569
4570 let previous_max = entries
4571 .iter()
4572 .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4573 .filter_map(|entry| entry.arp_max_version.as_deref())
4574 .max_by(|left, right| compare_version(left, right));
4575 if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4576 return None;
4577 }
4578
4579 Some(format!("< {}", latest.version))
4580}
4581
4582fn greater_than_latest_arp_anchored_version(
4587 entries: &[PackageVersionDataEntry],
4588 arp_version: &str,
4589 catalog_latest_version: &str,
4590) -> Option<String> {
4591 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4592 return None;
4593 }
4594
4595 let latest = entries
4596 .iter()
4597 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4598 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4599 let latest_max = latest.arp_max_version.as_deref()?;
4600 if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4601 return None;
4602 }
4603 if !has_different_leading_version_number(latest_max, &latest.version) {
4604 return None;
4605 }
4606 if compare_version(arp_version, latest_max) != Ordering::Greater {
4607 return None;
4608 }
4609
4610 Some(format!("> {}", latest.version))
4611}
4612
4613fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4614 let Some(left) = leading_version_number(left) else {
4615 return false;
4616 };
4617 let Some(right) = leading_version_number(right) else {
4618 return false;
4619 };
4620 !left.eq_ignore_ascii_case(right)
4621}
4622
4623fn leading_version_number(value: &str) -> Option<&str> {
4624 let trimmed = value.trim_start();
4625 let length = trimmed
4626 .chars()
4627 .take_while(|ch| ch.is_ascii_digit())
4628 .map(char::len_utf8)
4629 .sum();
4630 (length > 0).then_some(&trimmed[..length])
4631}
4632
4633fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4642 entries
4643 .iter()
4644 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4645 .max_by(|a, b| compare_version(&a.version, &b.version))
4646 .map(|entry| entry.version.clone())
4647}
4648
4649#[cfg(windows)]
4650fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4651 let mut packages = Vec::new();
4652 let mut seen = BTreeSet::new();
4653
4654 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4655 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4656
4657 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4665
4666 if machine {
4667 collect_uninstall_view(
4668 &mut packages,
4669 &mut seen,
4670 &upgrade_codes,
4671 RegKey::predef(HKEY_LOCAL_MACHINE),
4672 "Machine",
4673 "X64",
4674 KEY_READ | KEY_WOW64_64KEY,
4675 )?;
4676 collect_uninstall_view(
4677 &mut packages,
4678 &mut seen,
4679 &upgrade_codes,
4680 RegKey::predef(HKEY_LOCAL_MACHINE),
4681 "Machine",
4682 "X86",
4683 KEY_READ | KEY_WOW64_32KEY,
4684 )?;
4685 collect_appmodel_packages(
4686 &mut packages,
4687 &mut seen,
4688 RegKey::predef(HKEY_LOCAL_MACHINE),
4689 "Machine",
4690 KEY_READ | KEY_WOW64_64KEY,
4691 )?;
4692 }
4693
4694 if user {
4695 collect_uninstall_view(
4696 &mut packages,
4697 &mut seen,
4698 &upgrade_codes,
4699 RegKey::predef(HKEY_CURRENT_USER),
4700 "User",
4701 "X64",
4702 KEY_READ,
4703 )?;
4704 collect_appmodel_packages(
4705 &mut packages,
4706 &mut seen,
4707 RegKey::predef(HKEY_CURRENT_USER),
4708 "User",
4709 KEY_READ,
4710 )?;
4711 }
4712
4713 Ok(packages)
4714}
4715
4716#[cfg(windows)]
4722fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4723 let mut map = HashMap::new();
4724 if include_machine {
4725 collect_msi_upgrade_codes_from(
4726 RegKey::predef(HKEY_LOCAL_MACHINE),
4727 r"SOFTWARE\Classes\Installer\UpgradeCodes",
4728 KEY_READ | KEY_WOW64_64KEY,
4729 &mut map,
4730 );
4731 }
4732 if include_user {
4733 collect_msi_upgrade_codes_from(
4734 RegKey::predef(HKEY_CURRENT_USER),
4735 r"Software\Microsoft\Installer\UpgradeCodes",
4736 KEY_READ,
4737 &mut map,
4738 );
4739 }
4740 map
4741}
4742
4743#[cfg(windows)]
4744fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4745 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4746 return;
4747 };
4748 for key_name in upgrade_codes.enum_keys().flatten() {
4749 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4750 continue;
4751 };
4752 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4753 continue;
4754 };
4755 for (value_name, _) in subkey.enum_values().flatten() {
4756 if let Some(product_code) = unflip_packed_guid(&value_name) {
4757 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4761 }
4762 }
4763 }
4764}
4765
4766#[cfg(windows)]
4771fn unflip_packed_guid(packed: &str) -> Option<String> {
4772 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4773 return None;
4774 }
4775 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4776 let bytes = packed.as_bytes();
4777 let mut reversed = String::with_capacity(32);
4778 let mut offset = 0;
4779 for size in CHUNKS {
4780 let chunk = &bytes[offset..offset + size];
4781 for &c in chunk.iter().rev() {
4782 reversed.push(c as char);
4783 }
4784 offset += size;
4785 }
4786 let r = reversed.to_ascii_lowercase();
4787 Some(format!(
4788 "{{{}-{}-{}-{}-{}}}",
4789 &r[0..8],
4790 &r[8..12],
4791 &r[12..16],
4792 &r[16..20],
4793 &r[20..32]
4794 ))
4795}
4796
4797#[cfg(not(windows))]
4798fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4799 Ok(Vec::new())
4800}
4801
4802#[cfg(windows)]
4803fn collect_uninstall_view(
4804 packages: &mut Vec<InstalledPackage>,
4805 seen: &mut BTreeSet<String>,
4806 upgrade_code_map: &HashMap<String, String>,
4807 root: RegKey,
4808 scope: &str,
4809 arch: &str,
4810 flags: u32,
4811) -> Result<()> {
4812 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4813
4814 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4815 Ok(key) => key,
4816 Err(_) => return Ok(()),
4817 };
4818
4819 for key_name in uninstall.enum_keys().flatten() {
4820 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4821 Ok(key) => key,
4822 Err(_) => continue,
4823 };
4824 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4825 {
4826 continue;
4827 }
4828
4829 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4830 continue;
4831 };
4832
4833 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4834 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4835 let publisher = read_reg_string(&subkey, "Publisher");
4836 let install_location = read_reg_string(&subkey, "InstallLocation");
4837 let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4838 let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4839 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4840 .into_iter()
4841 .collect::<Vec<_>>();
4842 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4843 if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4844 product_codes.push(key_name.to_ascii_lowercase());
4845 }
4846 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4847 if upgrade_codes.is_empty() {
4848 for code in &product_codes {
4852 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4853 upgrade_codes.push(uc.clone());
4854 break;
4855 }
4856 }
4857 }
4858 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4859 .as_deref()
4860 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4861 {
4862 Some("portable".to_owned())
4868 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4869 Some("msi".to_owned())
4870 } else if key_name.starts_with("MSIX\\") {
4871 Some("msix".to_owned())
4872 } else {
4873 Some("exe".to_owned())
4874 };
4875
4876 let dedupe_key = format!(
4877 "{}|{}|{}|{}",
4878 local_id,
4879 name.to_ascii_lowercase(),
4880 installed_version.to_ascii_lowercase(),
4881 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4882 );
4883 if !seen.insert(dedupe_key) {
4884 continue;
4885 }
4886
4887 packages.push(InstalledPackage {
4888 name,
4889 local_id,
4890 installed_version,
4891 publisher,
4892 scope: Some(scope.to_owned()),
4893 installer_category,
4894 install_location,
4895 winget_package_identifier,
4896 winget_source_identifier,
4897 package_family_names,
4898 product_codes,
4899 upgrade_codes,
4900 correlated: None,
4901 installed_version_canonical: false,
4902 correlated_requires_explicit_upgrade: false,
4903 correlated_lacks_compatible_installer: false,
4904 });
4905 }
4906
4907 Ok(())
4908}
4909
4910#[cfg(windows)]
4911fn collect_appmodel_packages(
4912 packages: &mut Vec<InstalledPackage>,
4913 seen: &mut BTreeSet<String>,
4914 root: RegKey,
4915 scope: &str,
4916 flags: u32,
4917) -> Result<()> {
4918 const APPMODEL_PACKAGES_PATH: &str =
4919 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4920
4921 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4922 Ok(key) => key,
4923 Err(_) => return Ok(()),
4924 };
4925
4926 for key_name in appmodel.enum_keys().flatten() {
4927 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4928 Ok(key) => key,
4929 Err(_) => continue,
4930 };
4931
4932 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4933 if install_location.as_deref().is_some_and(is_windows_system_path) {
4934 continue;
4935 }
4936
4937 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4938 continue;
4939 };
4940 if is_msix_split_resource_package(&metadata.resource_id) {
4941 continue;
4942 }
4943
4944 let Some(name) = read_reg_string(&subkey, "DisplayName")
4945 .or_else(|| read_appmodel_display_name(&subkey))
4946 .filter(|value| !value.is_empty())
4947 else {
4948 continue;
4949 };
4950
4951 let local_id = format!(r"MSIX\{key_name}");
4952 let dedupe_key = format!(
4953 "{}|{}|{}",
4954 local_id,
4955 name.to_ascii_lowercase(),
4956 metadata.version.to_ascii_lowercase()
4957 );
4958 if !seen.insert(dedupe_key) {
4959 continue;
4960 }
4961
4962 packages.push(InstalledPackage {
4963 name,
4964 local_id,
4965 installed_version: metadata.version,
4966 publisher: None,
4967 scope: Some(scope.to_owned()),
4968 installer_category: Some("msix".to_owned()),
4969 install_location,
4970 winget_package_identifier: None,
4971 winget_source_identifier: None,
4972 package_family_names: vec![metadata.family_name],
4973 product_codes: Vec::new(),
4974 upgrade_codes: Vec::new(),
4975 correlated: None,
4976 installed_version_canonical: false,
4977 correlated_requires_explicit_upgrade: false,
4978 correlated_lacks_compatible_installer: false,
4979 });
4980 }
4981
4982 Ok(())
4983}
4984
4985#[cfg(windows)]
4986struct ParsedMsixPackageFullName {
4987 version: String,
4988 resource_id: String,
4989 family_name: String,
4990}
4991
4992#[cfg(windows)]
4993fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4994 let segments = value.split('_').collect::<Vec<_>>();
4995 if segments.len() < 5 {
4996 return None;
4997 }
4998
4999 let name = segments[..segments.len() - 4].join("_");
5000 if name.is_empty() {
5001 return None;
5002 }
5003
5004 let version = segments[segments.len() - 4].trim();
5005 let resource_id = segments[segments.len() - 2].trim();
5006 let publisher_id = segments[segments.len() - 1].trim();
5007 if version.is_empty() || publisher_id.is_empty() {
5008 return None;
5009 }
5010
5011 let family_name = if resource_id.is_empty() {
5012 format!("{name}_{publisher_id}")
5013 } else {
5014 format!("{name}_{resource_id}_{publisher_id}")
5015 };
5016
5017 Some(ParsedMsixPackageFullName {
5018 version: version.to_owned(),
5019 resource_id: resource_id.to_owned(),
5020 family_name,
5021 })
5022}
5023
5024#[cfg(windows)]
5025fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5026 let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5027 read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5028}
5029
5030#[cfg(windows)]
5031fn is_msix_split_resource_package(resource_id: &str) -> bool {
5032 resource_id.trim().to_ascii_lowercase().starts_with("split.")
5033}
5034
5035#[cfg(windows)]
5036fn is_windows_system_path(path: &str) -> bool {
5037 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5038}
5039
5040#[cfg(windows)]
5041fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5042 key.get_value::<String, _>(value_name)
5043 .ok()
5044 .map(|value| value.trim().to_owned())
5045 .filter(|value| !value.is_empty())
5046}
5047
5048#[cfg(windows)]
5049fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5050 key.get_value::<u32, _>(value_name).ok()
5051}
5052
5053fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5054 fs::create_dir_all(app_root).context("failed to create app root directory")?;
5055 if uses_packaged_layout(app_root) {
5056 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5057 let user_sources_path = packaged_user_sources_path(app_root)?;
5058 if let Some(parent) = user_sources_path.parent() {
5059 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5060 }
5061 } else {
5062 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5063 }
5064 Ok(())
5065}
5066
5067fn default_app_root() -> Result<PathBuf> {
5068 if let Some(app_root) = std::env::var_os(APP_ROOT_ENV_VAR) {
5069 return Ok(PathBuf::from(app_root));
5070 }
5071
5072 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5073
5074 #[cfg(windows)]
5075 {
5076 if has_current_package_identity() {
5077 return Ok(local_app_data
5078 .join("Packages")
5079 .join(PACKAGED_FAMILY_NAME)
5080 .join("LocalState"));
5081 }
5082 Ok(local_app_data.join("Devolutions").join("Pinget"))
5083 }
5084
5085 #[cfg(not(windows))]
5086 {
5087 Ok(local_app_data.join("pinget"))
5088 }
5089}
5090
5091#[cfg(windows)]
5092fn has_current_package_identity() -> bool {
5093 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5096
5097 let mut length: u32 = 0;
5098 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5101 rc != APPMODEL_ERROR_NO_PACKAGE
5102}
5103
5104fn store_path(app_root: &Path) -> PathBuf {
5105 app_root.join("sources.json")
5106}
5107
5108fn user_settings_path(app_root: &Path) -> PathBuf {
5109 if uses_packaged_layout(app_root) {
5110 app_root.join("settings.json")
5111 } else {
5112 app_root.join("user-settings.json")
5113 }
5114}
5115
5116fn admin_settings_path(app_root: &Path) -> PathBuf {
5117 app_root.join("admin-settings.json")
5118}
5119
5120#[cfg(test)]
5121fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5122 source_state_dir_with_keying(app_root, source, false)
5123}
5124
5125fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5126 if uses_packaged_layout(app_root) {
5127 return app_root
5128 .join(packaged_source_type(source.kind))
5129 .join(sanitize_path_segment(&source.identifier));
5130 }
5131
5132 if identifier_keyed {
5133 return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5134 }
5135
5136 app_root.join("sources").join(sanitize_path_segment(&source.name))
5137}
5138
5139fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5140 if uses_packaged_layout(app_root) {
5141 return app_root
5142 .join(sanitize_path_segment(&source.identifier))
5143 .join("installed.db");
5144 }
5145
5146 source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5147}
5148
5149#[cfg(test)]
5150fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5151 source_state_dir(app_root, source).join("index.db")
5152}
5153
5154fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5155 if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5156 source.etag = Some(value.to_owned());
5157 }
5158
5159 if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5160 source.last_modified = Some(value.to_owned());
5161 }
5162}
5163
5164fn remove_sqlite_sidecars(db_path: &Path) {
5168 for suffix in ["-wal", "-shm"] {
5169 let mut sidecar = db_path.as_os_str().to_owned();
5170 sidecar.push(suffix);
5171 let _ = fs::remove_file(PathBuf::from(sidecar));
5172 }
5173}
5174
5175#[cfg(windows)]
5176const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5177
5178#[cfg(windows)]
5179fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5180 let source_wide = source
5181 .as_os_str()
5182 .encode_wide()
5183 .chain(std::iter::once(0))
5184 .collect::<Vec<_>>();
5185 let target_wide = target
5186 .as_os_str()
5187 .encode_wide()
5188 .chain(std::iter::once(0))
5189 .collect::<Vec<_>>();
5190 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5191 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5194 if ok == 0 {
5195 return Err(std::io::Error::last_os_error());
5196 }
5197 Ok(())
5198}
5199
5200#[cfg(windows)]
5201fn replace_file(source: &Path, target: &Path) -> Result<()> {
5202 let to_err = |error: std::io::Error| -> anyhow::Error {
5203 anyhow::Error::new(error).context(format!(
5204 "failed to replace {} with {}",
5205 target.display(),
5206 source.display()
5207 ))
5208 };
5209
5210 let first = match move_replace_existing(source, target) {
5212 Ok(()) => return Ok(()),
5213 Err(error) => error,
5214 };
5215
5216 if !source.exists() || !target.exists() {
5219 return Err(to_err(first));
5220 }
5221
5222 let mut last = first;
5227 for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5228 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5229 match move_replace_existing(source, target) {
5230 Ok(()) => return Ok(()),
5231 Err(error) => last = error,
5232 }
5233 }
5234
5235 let staged = target.with_extension("old.tmp");
5241 if move_replace_existing(target, &staged).is_err() {
5242 return Err(to_err(last));
5243 }
5244 match move_replace_existing(source, target) {
5245 Ok(()) => {
5246 let _ = fs::remove_file(&staged);
5247 Ok(())
5248 }
5249 Err(error) => {
5250 let _ = move_replace_existing(&staged, target);
5251 Err(to_err(error))
5252 }
5253 }
5254}
5255
5256#[cfg(not(windows))]
5257fn replace_file(source: &Path, target: &Path) -> Result<()> {
5258 fs::rename(source, target)
5259 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5260}
5261
5262fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5263 cache_root(app_root)
5264 .join(bucket)
5265 .join(sanitize_path_segment(identifier))
5266}
5267
5268fn load_store(app_root: &Path) -> Result<SourceStore> {
5269 if uses_packaged_layout(app_root)
5270 && let Some(store) = load_packaged_store(app_root)?
5271 {
5272 return Ok(store);
5273 }
5274
5275 let path = store_path(app_root);
5276 if !path.exists() {
5277 let store = SourceStore::default();
5278 save_store(app_root, &store)?;
5279 return Ok(store);
5280 }
5281
5282 let bytes = fs::read(path).context("failed to read source store")?;
5283 serde_json::from_slice(&bytes).context("failed to parse source store")
5284}
5285
5286fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5287 if uses_system_winget_source_commands(app_root) {
5288 return Ok((
5289 EffectiveSourceMode::SystemWingetDirect,
5290 load_system_winget_source_store()?,
5291 ));
5292 }
5293
5294 match source_mode {
5295 SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5296 SourceMode::SystemWingetMirror => {
5297 let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5298 Ok((EffectiveSourceMode::SystemWingetMirror, store))
5299 }
5300 SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5301 Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5302 Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5303 },
5304 }
5305}
5306
5307fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5308 app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5309}
5310
5311fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5312 let path = system_winget_mirror_store_path(app_root);
5313 if !path.exists() {
5314 return Ok(None);
5315 }
5316
5317 let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5318 serde_json::from_slice(&bytes)
5319 .context("failed to parse system WinGet mirror source store")
5320 .map(Some)
5321}
5322
5323fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5324 write_json(system_winget_mirror_store_path(app_root), store)
5325}
5326
5327fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5328 if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5329 return Ok(store);
5330 }
5331
5332 refresh_system_winget_mirror_store(app_root)
5333}
5334
5335fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5336 let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5337 let mut exported = load_system_winget_source_store()?;
5338 merge_source_cache_metadata(&mut exported, &prior);
5339 save_system_winget_mirror_store(app_root, &exported)?;
5340 Ok(exported)
5341}
5342
5343fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5344 let prior_by_identifier: HashMap<_, _> = prior
5345 .sources
5346 .iter()
5347 .map(|source| (source.identifier.to_ascii_lowercase(), source))
5348 .collect();
5349
5350 for source in &mut target.sources {
5351 let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5352 continue;
5353 };
5354 source.last_update = previous.last_update;
5355 source.source_version = previous.source_version.clone();
5356 source.etag = previous.etag.clone();
5357 source.last_modified = previous.last_modified.clone();
5358 }
5359}
5360
5361fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5362 if uses_system_winget_source_commands(app_root) {
5363 bail!("System WinGet source settings must be modified through winget.");
5364 }
5365
5366 if uses_packaged_layout(app_root) {
5367 return save_packaged_store(app_root, store);
5368 }
5369
5370 write_json(store_path(app_root), store)
5371}
5372
5373fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5374 let user_sources_path = packaged_user_sources_path(app_root)?;
5375 let metadata_path = app_root.join("sources_metadata");
5376 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5377 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5378 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5379}
5380
5381fn load_packaged_store_from_streams(
5382 user_sources_yaml: Option<&str>,
5383 metadata_yaml: Option<&str>,
5384) -> Result<Option<SourceStore>> {
5385 if is_secure_settings_stub(user_sources_yaml) {
5386 return load_system_winget_source_store().map(Some);
5387 }
5388
5389 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5390}
5391
5392fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5393 let user_sources_path = packaged_user_sources_path(app_root)?;
5394 if let Some(parent) = user_sources_path.parent() {
5395 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5396 }
5397
5398 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5399 .context("failed to write packaged user_sources stream")?;
5400 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5401 .context("failed to write packaged source metadata")?;
5402 Ok(())
5403}
5404
5405fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5406 if !uses_packaged_layout(app_root) {
5407 return false;
5408 }
5409
5410 #[cfg(windows)]
5411 {
5412 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5413 return false;
5414 };
5415
5416 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5417 is_secure_settings_stub(user_sources_yaml.as_deref())
5418 }
5419
5420 #[cfg(not(windows))]
5421 {
5422 false
5423 }
5424}
5425
5426fn is_secure_settings_stub(value: Option<&str>) -> bool {
5427 value
5428 .map(str::trim_start)
5429 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5430}
5431
5432fn load_system_winget_source_store() -> Result<SourceStore> {
5433 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5434 Ok(SourceStore {
5435 sources: parse_system_winget_source_export(&result.stdout)?,
5436 })
5437}
5438
5439fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5440 run_checked_winget_source_command(
5441 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5442 &format!("add WinGet source '{name}'"),
5443 )?;
5444 Ok(())
5445}
5446
5447fn system_winget_remove_source(name: &str) -> Result<()> {
5448 run_checked_winget_source_command(
5449 vec![
5450 "source".to_owned(),
5451 "remove".to_owned(),
5452 "--name".to_owned(),
5453 name.to_owned(),
5454 "--disable-interactivity".to_owned(),
5455 ],
5456 &format!("remove WinGet source '{name}'"),
5457 )?;
5458 Ok(())
5459}
5460
5461fn system_winget_reset_source(name: &str) -> Result<()> {
5462 run_checked_winget_source_command(
5463 vec![
5464 "source".to_owned(),
5465 "reset".to_owned(),
5466 "--name".to_owned(),
5467 name.to_owned(),
5468 "--force".to_owned(),
5469 "--disable-interactivity".to_owned(),
5470 ],
5471 &format!("reset WinGet source '{name}'"),
5472 )?;
5473 Ok(())
5474}
5475
5476fn system_winget_reset_sources() -> Result<()> {
5477 run_checked_winget_source_command(
5478 vec![
5479 "source".to_owned(),
5480 "reset".to_owned(),
5481 "--force".to_owned(),
5482 "--disable-interactivity".to_owned(),
5483 ],
5484 "reset WinGet sources",
5485 )?;
5486 Ok(())
5487}
5488
5489fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5490 let mut args = vec!["source".to_owned(), "update".to_owned()];
5491 if let Some(name) = name {
5492 args.push("--name".to_owned());
5493 args.push(name.to_owned());
5494 }
5495
5496 args.push("--disable-interactivity".to_owned());
5497 let result = run_checked_winget_source_command(
5498 args,
5499 if name.is_some() {
5500 "update WinGet source"
5501 } else {
5502 "update WinGet sources"
5503 },
5504 )?;
5505 let detail = result.stdout.trim();
5506 Ok(if detail.is_empty() {
5507 "Done".to_owned()
5508 } else {
5509 detail.to_owned()
5510 })
5511}
5512
5513fn build_winget_source_export_arguments() -> Vec<String> {
5514 vec![
5515 "source".to_owned(),
5516 "export".to_owned(),
5517 "--disable-interactivity".to_owned(),
5518 ]
5519}
5520
5521fn build_winget_source_add_arguments(
5522 name: &str,
5523 arg: &str,
5524 kind: SourceKind,
5525 trust_level: &str,
5526 explicit: bool,
5527) -> Vec<String> {
5528 let mut args = vec![
5529 "source".to_owned(),
5530 "add".to_owned(),
5531 "--name".to_owned(),
5532 name.to_owned(),
5533 "--arg".to_owned(),
5534 arg.to_owned(),
5535 "--type".to_owned(),
5536 packaged_source_type(kind).to_owned(),
5537 "--disable-interactivity".to_owned(),
5538 ];
5539
5540 if !trust_level.trim().is_empty() {
5541 args.push("--trust-level".to_owned());
5542 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5543 "trusted".to_owned()
5544 } else {
5545 "none".to_owned()
5546 });
5547 }
5548
5549 if explicit {
5550 args.push("--explicit".to_owned());
5551 }
5552
5553 args
5554}
5555
5556#[derive(Debug)]
5557struct WingetSourceCommandResult {
5558 exit_code: i32,
5559 stdout: String,
5560 stderr: String,
5561}
5562
5563type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5564
5565static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5566 RwLock::new(run_winget_source_command_process);
5567
5568fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5569 let result = run_winget_source_command(&args)?;
5570 if result.exit_code == 0 {
5571 return Ok(result);
5572 }
5573
5574 let detail = [result.stderr.trim(), result.stdout.trim()]
5575 .into_iter()
5576 .filter(|value| !value.is_empty())
5577 .collect::<Vec<_>>()
5578 .join("\n");
5579 if detail.is_empty() {
5580 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5581 }
5582
5583 bail!(
5584 "Failed to {action}; winget exited with code {}:\n{}",
5585 result.exit_code,
5586 detail
5587 );
5588}
5589
5590fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5591 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5592 .read()
5593 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5594 runner(args)
5595}
5596
5597fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5598 use std::process::Command;
5599
5600 let output = Command::new("winget")
5601 .args(args)
5602 .output()
5603 .context("failed to run winget source command")?;
5604
5605 Ok(WingetSourceCommandResult {
5606 exit_code: output.status.code().unwrap_or(-1),
5607 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5608 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5609 })
5610}
5611
5612fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5613 let trimmed = output.trim();
5614 if trimmed.is_empty() {
5615 return Ok(Vec::new());
5616 }
5617
5618 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5619 && let Some(sources) = parse_system_winget_source_export_value(&value)?
5620 {
5621 return Ok(sources);
5622 }
5623
5624 let mut sources = Vec::new();
5625 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5626 if line.is_empty() {
5627 continue;
5628 }
5629
5630 let value: JsonValue = serde_json::from_str(line)
5631 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5632 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5633 bail!("WinGet source export returned a non-source line: {line}");
5634 };
5635 sources.append(&mut parsed);
5636 }
5637
5638 Ok(sources)
5639}
5640
5641fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5642 match value {
5643 JsonValue::Array(items) => items
5644 .iter()
5645 .map(parse_system_winget_source_record)
5646 .collect::<Result<Vec<_>>>()
5647 .map(Some),
5648 JsonValue::Object(object) => {
5649 if let Some(JsonValue::Array(items)) = object.get("Sources") {
5650 items
5651 .iter()
5652 .map(parse_system_winget_source_record)
5653 .collect::<Result<Vec<_>>>()
5654 .map(Some)
5655 } else if object.contains_key("Name") {
5656 Ok(Some(vec![parse_system_winget_source_record(value)?]))
5657 } else {
5658 Ok(None)
5659 }
5660 }
5661 _ => Ok(None),
5662 }
5663}
5664
5665fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5666 let object = value
5667 .as_object()
5668 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5669 let name = required_json_string(object, "Name")?;
5670 let source_type = required_json_string(object, "Type")?;
5671 let arg = required_json_string(object, "Arg")?;
5672 let kind = parse_system_winget_source_kind(&source_type)?;
5673 let identifier = optional_json_string(object, "Identifier")
5674 .or_else(|| optional_json_string(object, "Data"))
5675 .unwrap_or_else(|| name.clone());
5676
5677 Ok(SourceRecord {
5678 name,
5679 kind,
5680 arg,
5681 identifier,
5682 trust_level: parse_system_winget_trust_level(object),
5683 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5684 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5685 last_update: None,
5686 source_version: None,
5687 etag: None,
5688 last_modified: None,
5689 })
5690}
5691
5692fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5693 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5694}
5695
5696fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5697 match object.get(key)? {
5698 JsonValue::String(value) => Some(value.clone()),
5699 JsonValue::Number(value) => Some(value.to_string()),
5700 _ => None,
5701 }
5702}
5703
5704fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5705 match object.get(key)? {
5706 JsonValue::Bool(value) => Some(*value),
5707 JsonValue::String(value) => value.parse().ok(),
5708 _ => None,
5709 }
5710}
5711
5712fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5713 match object.get(key)? {
5714 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5715 JsonValue::String(value) => value.parse().ok(),
5716 _ => None,
5717 }
5718}
5719
5720fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5721 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5722 Ok(SourceKind::PreIndexed)
5723 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5724 Ok(SourceKind::Rest)
5725 } else {
5726 bail!("Unsupported WinGet source type '{value}'.")
5727 }
5728}
5729
5730fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5731 let Some(value) = object.get("TrustLevel") else {
5732 return "None".to_owned();
5733 };
5734
5735 match value {
5736 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5737 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5738 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5739 _ => "None".to_owned(),
5740 }
5741}
5742
5743fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5744 value
5745 .as_str()
5746 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5747}
5748
5749fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5750 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5751 return None;
5752 }
5753
5754 let mut sources = SourceStore::default().sources;
5755 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5756 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5757 continue;
5758 };
5759
5760 if yaml_bool(entry.get("IsTombstone")) {
5761 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5762 continue;
5763 }
5764
5765 let Some(mapped) = map_packaged_source_entry(&entry) else {
5766 continue;
5767 };
5768
5769 if let Some(existing) = sources
5770 .iter_mut()
5771 .find(|source| source.name.eq_ignore_ascii_case(&name))
5772 {
5773 *existing = mapped;
5774 } else {
5775 sources.push(mapped);
5776 }
5777 }
5778
5779 for entry in parse_packaged_yaml_entries(metadata_yaml) {
5780 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5781 continue;
5782 };
5783
5784 let Some(source) = sources
5785 .iter_mut()
5786 .find(|source| source.name.eq_ignore_ascii_case(&name))
5787 else {
5788 continue;
5789 };
5790
5791 if let Some(last_update) = entry
5792 .get("LastUpdate")
5793 .and_then(|value| value.as_deref())
5794 .and_then(parse_packaged_datetime)
5795 {
5796 source.last_update = Some(last_update);
5797 }
5798
5799 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
5800 && !source_version.is_empty()
5801 {
5802 source.source_version = Some(source_version);
5803 }
5804
5805 if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
5806 && !etag.is_empty()
5807 {
5808 source.etag = Some(etag);
5809 }
5810
5811 if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
5812 && !last_modified.is_empty()
5813 {
5814 source.last_modified = Some(last_modified);
5815 }
5816 }
5817
5818 Some(SourceStore { sources })
5819}
5820
5821fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5822 let Some(yaml) = yaml else {
5823 return Vec::new();
5824 };
5825
5826 let mut entries = Vec::new();
5827 let mut current: Option<BTreeMap<String, Option<String>>> = None;
5828 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5829 let line = raw_line.trim_end();
5830 let trimmed = line.trim();
5831 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5832 continue;
5833 }
5834
5835 if let Some(remainder) = trimmed.strip_prefix("- ") {
5836 if let Some(entry) = current.take() {
5837 entries.push(entry);
5838 }
5839
5840 let mut entry = BTreeMap::new();
5841 add_packaged_yaml_key_value(&mut entry, remainder);
5842 current = Some(entry);
5843 continue;
5844 }
5845
5846 if let Some(entry) = current.as_mut() {
5847 add_packaged_yaml_key_value(entry, trimmed);
5848 }
5849 }
5850
5851 if let Some(entry) = current {
5852 entries.push(entry);
5853 }
5854
5855 entries
5856}
5857
5858fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5859 let Some((key, value)) = line.split_once(':') else {
5860 return;
5861 };
5862
5863 entry.insert(
5864 key.trim().to_owned(),
5865 Some(unquote_yaml_scalar(value.trim()).to_owned()),
5866 );
5867}
5868
5869fn unquote_yaml_scalar(value: &str) -> &str {
5870 if value.len() >= 2 {
5871 let bytes = value.as_bytes();
5872 let first = bytes[0] as char;
5873 let last = bytes[value.len() - 1] as char;
5874 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5875 return &value[1..value.len() - 1];
5876 }
5877 }
5878
5879 value
5880}
5881
5882fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5883 let name = entry.get("Name").and_then(|value| value.clone())?;
5884 let source_type = entry.get("Type").and_then(|value| value.clone())?;
5885 let arg = entry.get("Arg").and_then(|value| value.clone())?;
5886 let kind = parse_packaged_source_kind(&source_type)?;
5887
5888 Some(SourceRecord {
5889 name: name.clone(),
5890 kind,
5891 arg,
5892 identifier: entry
5893 .get("Identifier")
5894 .and_then(|value| value.clone())
5895 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5896 .unwrap_or(name),
5897 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5898 explicit: yaml_bool(entry.get("Explicit")),
5899 priority: entry
5900 .get("Priority")
5901 .and_then(|value| value.as_deref())
5902 .and_then(|value| value.parse::<i32>().ok())
5903 .unwrap_or_default(),
5904 last_update: None,
5905 source_version: None,
5906 etag: None,
5907 last_modified: None,
5908 })
5909}
5910
5911fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5912 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5913 Some(SourceKind::PreIndexed)
5914 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5915 Some(SourceKind::Rest)
5916 } else {
5917 None
5918 }
5919}
5920
5921fn parse_packaged_trust_level(value: Option<&str>) -> String {
5922 match value {
5923 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5924 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5925 _ => "None".to_owned(),
5926 }
5927}
5928
5929fn yaml_bool(value: Option<&Option<String>>) -> bool {
5930 value
5931 .and_then(|raw| raw.as_deref())
5932 .and_then(|raw| raw.parse::<bool>().ok())
5933 .unwrap_or(false)
5934}
5935
5936fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5937 if let Ok(unix_seconds) = value.parse::<i64>() {
5938 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5939 }
5940
5941 DateTime::parse_from_rfc3339(value)
5942 .ok()
5943 .map(|value| value.with_timezone(&Utc))
5944}
5945
5946fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5947 let mut lines = vec!["Sources:".to_owned()];
5948 for source in &store.sources {
5949 lines.push(format!(" - Name: {}", source.name));
5950 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
5951 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
5952 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
5953 lines.push(format!(
5954 " TrustLevel: {}",
5955 if source.trust_level.eq_ignore_ascii_case("Trusted") {
5956 1
5957 } else {
5958 0
5959 }
5960 ));
5961 lines.push(format!(" Explicit: {}", source.explicit));
5962 lines.push(format!(" Priority: {}", source.priority));
5963 lines.push(" IsTombstone: false".to_owned());
5964 }
5965 lines.push(String::new());
5966 lines.join("\n")
5967}
5968
5969fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5970 let mut lines = vec!["Sources:".to_owned()];
5971 for source in &store.sources {
5972 if source.last_update.is_none()
5973 && source.source_version.is_none()
5974 && source.etag.is_none()
5975 && source.last_modified.is_none()
5976 {
5977 continue;
5978 }
5979
5980 lines.push(format!(" - Name: {}", source.name));
5981 if let Some(last_update) = source.last_update {
5982 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
5983 }
5984 if let Some(source_version) = &source.source_version {
5985 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
5986 }
5987 if let Some(etag) = &source.etag {
5988 lines.push(format!(" ETag: {}", yaml_scalar(etag)));
5989 }
5990 if let Some(last_modified) = &source.last_modified {
5991 lines.push(format!(" LastModified: {}", yaml_scalar(last_modified)));
5992 }
5993 }
5994 lines.push(String::new());
5995 lines.join("\n")
5996}
5997
5998fn yaml_scalar(value: &str) -> String {
5999 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
6000 format!("\"{}\"", value.replace('"', "\\\""))
6001 } else {
6002 value.to_owned()
6003 }
6004}
6005
6006fn uses_packaged_layout(app_root: &Path) -> bool {
6007 #[cfg(windows)]
6008 {
6009 try_get_packaged_local_state_root(app_root).is_some()
6010 }
6011
6012 #[cfg(not(windows))]
6013 {
6014 let _ = app_root;
6015 false
6016 }
6017}
6018
6019#[cfg(windows)]
6020fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
6021 let local_state = app_root.file_name()?.to_string_lossy();
6022 if !local_state.eq_ignore_ascii_case("LocalState") {
6023 return None;
6024 }
6025
6026 let family = app_root.parent()?.file_name()?.to_string_lossy();
6027 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6028 return None;
6029 }
6030
6031 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6032 if !packages.eq_ignore_ascii_case("Packages") {
6033 return None;
6034 }
6035
6036 Some(app_root.to_path_buf())
6037}
6038
6039#[cfg(windows)]
6040fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6041 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6042 Ok(packaged_secure_settings_root()?.join("user_sources"))
6043}
6044
6045#[cfg(not(windows))]
6046fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6047 bail!("packaged WinGet source streams are only available on Windows")
6048}
6049
6050#[cfg(windows)]
6051fn packaged_secure_settings_root() -> Result<PathBuf> {
6052 let program_data = std::env::var_os("PROGRAMDATA")
6053 .map(PathBuf::from)
6054 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6055 Ok(program_data
6056 .join("Microsoft")
6057 .join("WinGet")
6058 .join(current_user_sid()?)
6059 .join("settings")
6060 .join("pkg")
6061 .join(PACKAGED_NAME))
6062}
6063
6064#[cfg(windows)]
6065#[allow(clippy::multiple_unsafe_ops_per_block)]
6066fn current_user_sid() -> Result<String> {
6067 fn close_token(token: HANDLE) {
6068 if !token.is_null() {
6069 unsafe {
6071 CloseHandle(token);
6072 }
6073 }
6074 }
6075
6076 let mut token: HANDLE = std::ptr::null_mut();
6077 let current_process = unsafe { GetCurrentProcess() };
6079 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6081 bail!("failed to open current process token")
6082 }
6083
6084 let mut length = 0;
6085 unsafe {
6087 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6088 }
6089
6090 let element_size = size_of::<TOKEN_USER>();
6091 let element_count = (length as usize).div_ceil(element_size);
6092 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6093 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6095 close_token(token);
6096 bail!("failed to get current user token information")
6097 }
6098
6099 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6101 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6102 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6104 close_token(token);
6105 bail!("failed to convert current user SID")
6106 }
6107
6108 let mut len = 0usize;
6109 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6111 len += 1;
6112 }
6113
6114 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6116 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6117 unsafe {
6119 LocalFree(sid_ptr.cast());
6120 }
6121 close_token(token);
6122 Ok(sid)
6123}
6124
6125fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6126 app_root.join("Microsoft").join("Windows Package Manager")
6127}
6128
6129fn cache_root(app_root: &Path) -> PathBuf {
6130 if uses_packaged_layout(app_root) {
6131 packaged_file_cache_root(app_root)
6132 } else {
6133 default_cache_root_fallback()
6134 }
6135}
6136
6137fn default_cache_root_fallback() -> PathBuf {
6138 std::env::temp_dir().join("cache")
6139}
6140
6141fn packaged_source_type(kind: SourceKind) -> &'static str {
6142 match kind {
6143 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6144 SourceKind::Rest => "Microsoft.Rest",
6145 }
6146}
6147
6148fn sanitize_path_segment(value: &str) -> String {
6149 value
6150 .chars()
6151 .map(|ch| match ch {
6152 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6153 _ => ch,
6154 })
6155 .collect()
6156}
6157
6158fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6159 if !path.exists() {
6160 return Ok(JsonMap::new());
6161 }
6162
6163 let bytes = fs::read(path).context("failed to read JSON file")?;
6164 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6165 Ok(match value {
6166 JsonValue::Object(object) => object,
6167 _ => JsonMap::new(),
6168 })
6169}
6170
6171fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6172 write_json(path, value)
6173}
6174
6175fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6176 if let Some(parent) = path.parent() {
6177 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6178 }
6179
6180 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6181 fs::write(path, bytes).context("failed to write JSON file")
6182}
6183
6184fn merge_json_objects(
6185 current: &JsonMap<String, JsonValue>,
6186 update: &JsonMap<String, JsonValue>,
6187) -> JsonMap<String, JsonValue> {
6188 let mut merged = current.clone();
6189 for (key, value) in update {
6190 match (merged.get(key), value) {
6191 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6192 merged.insert(
6193 key.clone(),
6194 JsonValue::Object(merge_json_objects(current_object, update_object)),
6195 );
6196 }
6197 _ => {
6198 merged.insert(key.clone(), value.clone());
6199 }
6200 }
6201 }
6202
6203 merged
6204}
6205
6206fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6207 match expected {
6208 JsonValue::Object(expected_object) => match current {
6209 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6210 current_object
6211 .get(key)
6212 .is_some_and(|current_child| json_contains(current_child, expected_child))
6213 }),
6214 _ => false,
6215 },
6216 JsonValue::Array(expected_array) => match current {
6217 JsonValue::Array(current_array) => {
6218 current_array.len() == expected_array.len()
6219 && current_array
6220 .iter()
6221 .zip(expected_array.iter())
6222 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6223 }
6224 _ => false,
6225 },
6226 _ => current == expected,
6227 }
6228}
6229
6230fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6231 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6232 "none" | "default" => Ok("None".to_owned()),
6233 "trusted" => Ok("Trusted".to_owned()),
6234 other => bail!("unsupported source trust level: {other}"),
6235 }
6236}
6237
6238fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6239 SUPPORTED_ADMIN_SETTINGS
6240 .iter()
6241 .copied()
6242 .find(|setting| setting.eq_ignore_ascii_case(name))
6243 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6244}
6245
6246fn default_source_trust_level() -> String {
6247 "None".to_owned()
6248}
6249
6250fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6251where
6252 F: FnMut(&SqlRow) -> Result<T>,
6253{
6254 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6255 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6256 let mut result = Vec::new();
6257
6258 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6259 result.push(map(&row)?);
6260 }
6261
6262 Ok(result)
6263}
6264
6265fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6266where
6267 F: FnOnce(&SqlRow) -> Result<T>,
6268{
6269 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6270 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6271
6272 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6273 Some(row) => Ok(Some(map(&row)?)),
6274 None => Ok(None),
6275 }
6276}
6277
6278fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6279 pollster::block_on(connection.execute(sql, params_from_iter(params)))
6280 .with_context(|| format!("failed to execute SQL statement: {sql}"))
6281}
6282
6283fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6284 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6285}
6286
6287fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6288 row.get_value(index)
6289 .with_context(|| format!("failed to read SQL column {index}"))
6290}
6291
6292fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6293 match row_value(row, index)? {
6294 SqlValue::Text(value) => Ok(value),
6295 SqlValue::Integer(value) => Ok(value.to_string()),
6296 SqlValue::Real(value) => Ok(value.to_string()),
6297 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6298 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6299 }
6300}
6301
6302fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6303 match row_value(row, index)? {
6304 SqlValue::Null => Ok(None),
6305 SqlValue::Text(value) => Ok(Some(value)),
6306 SqlValue::Integer(value) => Ok(Some(value.to_string())),
6307 SqlValue::Real(value) => Ok(Some(value.to_string())),
6308 SqlValue::Blob(value) => Ok(Some(
6309 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6310 )),
6311 }
6312}
6313
6314fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6315 match row_value(row, index)? {
6316 SqlValue::Integer(value) => Ok(value),
6317 SqlValue::Text(value) => value
6318 .parse::<i64>()
6319 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6320 SqlValue::Real(value) => f64_to_i64_checked(value, index),
6321 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6322 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6323 }
6324}
6325
6326fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6327 match row_value(row, index)? {
6328 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6329 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6330 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6331 SqlValue::Integer(value) => Ok(format!("{value:x}")),
6332 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6333 }
6334}
6335
6336fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6337 match row_value(row, index)? {
6338 SqlValue::Null => Ok(None),
6339 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6340 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6341 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6342 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6343 }
6344}
6345
6346fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6347 if !value.is_finite() {
6348 bail!("SQL column {index} contained a non-finite floating-point value");
6349 }
6350
6351 if value.fract() != 0.0 {
6352 bail!("SQL column {index} contained a non-integer floating-point value");
6353 }
6354
6355 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6356 bail!("SQL column {index} contained a floating-point value outside the i64 range");
6357 }
6358
6359 format!("{value:.0}")
6360 .parse::<i64>()
6361 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6362}
6363
6364fn bytes_to_hex(bytes: &[u8]) -> String {
6365 let mut result = String::with_capacity(bytes.len() * 2);
6366 for byte in bytes {
6367 result.push_str(&format!("{byte:02x}"));
6368 }
6369 result
6370}
6371
6372fn query_v2_matches(
6373 connection: &Connection,
6374 query: &PackageQuery,
6375 semantics: SearchSemantics,
6376) -> Result<(Vec<V2SearchRow>, bool)> {
6377 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6378 let limit = source_fetch_results(query, semantics);
6379 let sql = format!(
6380 "SELECT rowid, id, name, moniker, latest_version, hash \
6381 FROM packages WHERE {where_clause} LIMIT {}",
6382 limit + 1
6383 );
6384 let mut rows = query_rows(
6385 connection,
6386 &sql,
6387 params.into_iter().map(SqlValue::Text).collect(),
6388 |row| {
6389 Ok(V2SearchRow {
6390 package_rowid: row_i64(row, 0)?,
6391 id: row_string(row, 1)?,
6392 name: row_string(row, 2)?,
6393 moniker: row_opt_string(row, 3)?,
6394 version: row_string(row, 4)?,
6395 package_hash: row_hex_string(row, 5)?,
6396 match_criteria: None,
6397 })
6398 },
6399 )?;
6400 for row in &mut rows {
6401 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6402 }
6403 let truncated = rows.len() > limit;
6404 rows.truncate(limit);
6405 Ok((rows, truncated))
6406}
6407
6408fn query_v1_matches(
6409 connection: &Connection,
6410 query: &PackageQuery,
6411 semantics: SearchSemantics,
6412) -> Result<(Vec<V1SearchRow>, bool)> {
6413 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6414 let limit = source_fetch_results(query, semantics);
6415 let sql = format!(
6416 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6417 FROM manifest \
6418 JOIN ids ON manifest.id = ids.rowid \
6419 JOIN names ON manifest.name = names.rowid \
6420 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6421 JOIN versions ON manifest.version = versions.rowid \
6422 JOIN channels ON manifest.channel = channels.rowid \
6423 WHERE {where_clause} LIMIT {}",
6424 limit + 1
6425 );
6426 let mut rows = query_rows(
6427 connection,
6428 &sql,
6429 params.into_iter().map(SqlValue::Text).collect(),
6430 |row| {
6431 Ok(V1SearchRow {
6432 manifest_rowid: row_i64(row, 0)?,
6433 package_rowid: row_i64(row, 1)?,
6434 version: row_string(row, 2)?,
6435 channel: row_string(row, 3)?,
6436 id: row_string(row, 4)?,
6437 name: row_string(row, 5)?,
6438 moniker: row_opt_string(row, 6)?,
6439 match_criteria: None,
6440 })
6441 },
6442 )?;
6443 for row in &mut rows {
6444 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6445 }
6446 let truncated = rows.len() > limit;
6447 rows.truncate(limit);
6448 Ok((rows, truncated))
6449}
6450
6451fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6452 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6453
6454 for row in rows {
6455 match grouped.get(&row.package_rowid) {
6456 Some(existing)
6457 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6458 != Ordering::Less => {}
6459 _ => {
6460 grouped.insert(row.package_rowid, row);
6461 }
6462 }
6463 }
6464
6465 grouped.into_values().collect()
6466}
6467
6468fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6469 let has_hash = table_has_column(connection, "manifest", "hash")?;
6470 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6471 let sql = format!(
6472 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6473 FROM manifest \
6474 JOIN versions ON manifest.version = versions.rowid \
6475 JOIN channels ON manifest.channel = channels.rowid \
6476 WHERE manifest.id = ?1"
6477 );
6478 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6479 Ok(V1VersionRow {
6480 version: row_string(row, 0)?,
6481 channel: row_string(row, 1)?,
6482 pathpart_id: row_i64(row, 2)?,
6483 manifest_hash: row_opt_hex_string(row, 3)?,
6484 })
6485 })
6486}
6487
6488fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6489 let mut parts = Vec::new();
6490 let mut current = Some(pathpart_id);
6491
6492 while let Some(id) = current {
6493 let (parent, pathpart) = query_optional_value(
6494 connection,
6495 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6496 vec![SqlValue::Integer(id)],
6497 |row| {
6498 Ok((
6499 row_opt_string(row, 0)?
6500 .and_then(|value| value.parse::<i64>().ok())
6501 .filter(|value| *value != 0),
6502 row_string(row, 1)?,
6503 ))
6504 },
6505 )?
6506 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6507 parts.push(pathpart);
6508 current = parent;
6509 }
6510
6511 parts.reverse();
6512 Ok(parts.join("/"))
6513}
6514
6515fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6516 format!(
6517 "packages/{}/{}/versionData.mszyml",
6518 package_id,
6519 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6520 )
6521}
6522
6523fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6524 let message = format!("{error:#}").to_ascii_lowercase();
6525 message.contains("no such table") && message.contains("packages")
6526}
6527
6528fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6529 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6530 row_string(row, 1)
6531 })? {
6532 if value.eq_ignore_ascii_case(column) {
6533 return Ok(true);
6534 }
6535 }
6536
6537 Ok(false)
6538}
6539
6540fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6541 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6542 let id_column = if v2 { "id" } else { "ids.id" };
6543 let name_column = if v2 { "name" } else { "names.name" };
6544 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6545 let exact_match = query.exact || semantics == SearchSemantics::Single;
6546 let mut params = Vec::new();
6547
6548 if let Some(value) = &query.id {
6549 return (
6550 single_field_condition(id_column, value, exact_match, &mut params),
6551 params,
6552 );
6553 }
6554 if let Some(value) = &query.name {
6555 return (
6556 single_field_condition(name_column, value, exact_match, &mut params),
6557 params,
6558 );
6559 }
6560 if let Some(value) = &query.moniker {
6561 return (
6562 single_field_condition(moniker_column, value, exact_match, &mut params),
6563 params,
6564 );
6565 }
6566 if let Some(value) = &query.tag {
6567 return (
6568 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6569 params,
6570 );
6571 }
6572 if let Some(value) = &query.command {
6573 return (
6574 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6575 params,
6576 );
6577 }
6578 if let Some(value) = &query.query {
6579 if exact_match {
6580 let conditions = [
6581 single_field_condition(id_column, value, true, &mut params),
6582 single_field_condition(name_column, value, true, &mut params),
6583 single_field_condition(moniker_column, value, true, &mut params),
6584 ];
6585 return (format!("({})", conditions.join(" OR ")), params);
6586 }
6587
6588 let mut conditions = vec![
6589 single_field_condition(id_column, value, false, &mut params),
6590 single_field_condition(name_column, value, false, &mut params),
6591 single_field_condition(moniker_column, value, false, &mut params),
6592 ];
6593 if semantics == SearchSemantics::Many {
6594 conditions.push(mapped_field_condition(
6595 v2,
6596 "tag",
6597 value,
6598 rowid_column,
6599 false,
6600 &mut params,
6601 ));
6602 conditions.push(mapped_field_condition(
6603 v2,
6604 "command",
6605 value,
6606 rowid_column,
6607 false,
6608 &mut params,
6609 ));
6610 }
6611 return (format!("({})", conditions.join(" OR ")), params);
6612 }
6613
6614 ("1 = 1".to_owned(), Vec::new())
6615}
6616
6617fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6618 params.push(match_parameter(value, exact));
6619 format!("{column} LIKE ?{}", params.len())
6620}
6621
6622fn mapped_field_condition(
6623 v2: bool,
6624 value_name: &str,
6625 value: &str,
6626 rowid_column: &str,
6627 exact: bool,
6628 params: &mut Vec<String>,
6629) -> String {
6630 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6631 (
6632 format!("{value_name}s2"),
6633 format!("{value_name}s2_map"),
6634 value_name.to_owned(),
6635 "package".to_owned(),
6636 )
6637 } else {
6638 (
6639 format!("{value_name}s"),
6640 format!("{value_name}s_map"),
6641 value_name.to_owned(),
6642 "manifest".to_owned(),
6643 )
6644 };
6645 params.push(match_parameter(value, exact));
6646 let parameter = params.len();
6647 format!(
6654 "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6655 FROM {map_table_name} JOIN {table_name} ON \
6656 {map_table_name}.{value_name} = {table_name}.rowid \
6657 WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6658 )
6659}
6660
6661fn match_parameter(value: &str, exact: bool) -> String {
6662 if exact { value.to_owned() } else { format!("%{value}%") }
6663}
6664
6665fn build_rest_search_body(
6666 query: &PackageQuery,
6667 info: &RestInformation,
6668 semantics: SearchSemantics,
6669) -> Result<JsonValue> {
6670 let mut root = serde_json::Map::new();
6671 root.insert(
6672 "MaximumResults".to_owned(),
6673 JsonValue::from(source_fetch_results(query, semantics) as u64),
6674 );
6675 let exact_match = query.exact || semantics == SearchSemantics::Single;
6676
6677 if let Some(value) = &query.query {
6678 if semantics == SearchSemantics::Single {
6679 let mut filters = vec![
6680 rest_filter("PackageIdentifier", value, true),
6681 rest_filter("PackageName", value, true),
6682 rest_filter("Moniker", value, true),
6683 ];
6684 append_required_rest_filters(&mut filters, info);
6685 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6686 return Ok(JsonValue::Object(root));
6687 }
6688
6689 root.insert(
6690 "Query".to_owned(),
6691 serde_json::json!({
6692 "KeyWord": value,
6693 "MatchType": if exact_match { "Exact" } else { "Substring" },
6694 }),
6695 );
6696 }
6697
6698 let mut filters = Vec::new();
6699 if let Some(value) = &query.id {
6700 filters.push(rest_filter("PackageIdentifier", value, exact_match));
6701 }
6702 if let Some(value) = &query.name {
6703 filters.push(rest_filter("PackageName", value, exact_match));
6704 }
6705 if let Some(value) = &query.moniker {
6706 filters.push(rest_filter("Moniker", value, exact_match));
6707 }
6708 if let Some(value) = &query.tag {
6709 filters.push(rest_filter("Tag", value, true));
6710 }
6711 if let Some(value) = &query.command {
6712 filters.push(rest_filter("Command", value, true));
6713 }
6714 append_required_rest_filters(&mut filters, info);
6715
6716 if !filters.is_empty() {
6717 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6718 }
6719
6720 Ok(JsonValue::Object(root))
6721}
6722
6723fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6724 if info
6725 .required_package_match_fields
6726 .iter()
6727 .any(|field| field.eq_ignore_ascii_case("Market"))
6728 {
6729 filters.push(rest_filter("Market", &default_market(), true));
6730 }
6731}
6732
6733fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6734 serde_json::json!({
6735 "PackageMatchField": field,
6736 "RequestMatch": {
6737 "KeyWord": value,
6738 "MatchType": if exact { "Exact" } else { "Substring" },
6739 }
6740 })
6741}
6742
6743fn max_results(query: &PackageQuery) -> usize {
6744 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6745}
6746
6747fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6748 match semantics {
6749 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6750 SearchSemantics::Single => max_results(query),
6751 }
6752}
6753
6754fn infer_preindexed_match_criteria_v2(
6755 connection: &Connection,
6756 row: &V2SearchRow,
6757 query: &PackageQuery,
6758 semantics: SearchSemantics,
6759) -> Result<Option<String>> {
6760 infer_match_criteria(
6761 &row.id,
6762 &row.name,
6763 row.moniker.as_deref(),
6764 query,
6765 semantics,
6766 |value| {
6767 find_mapped_value_v2(
6768 connection,
6769 "tags2",
6770 "tags2_map",
6771 "tag",
6772 row.package_rowid,
6773 value,
6774 query.exact,
6775 )
6776 },
6777 |value| {
6778 find_mapped_value_v2(
6779 connection,
6780 "commands2",
6781 "commands2_map",
6782 "command",
6783 row.package_rowid,
6784 value,
6785 query.exact,
6786 )
6787 },
6788 )
6789}
6790
6791fn infer_preindexed_match_criteria_v1(
6792 connection: &Connection,
6793 row: &V1SearchRow,
6794 query: &PackageQuery,
6795 semantics: SearchSemantics,
6796) -> Result<Option<String>> {
6797 infer_match_criteria(
6798 &row.id,
6799 &row.name,
6800 row.moniker.as_deref(),
6801 query,
6802 semantics,
6803 |value| {
6804 find_mapped_value_v1(
6805 connection,
6806 "tags",
6807 "tags_map",
6808 "tag",
6809 row.manifest_rowid,
6810 value,
6811 query.exact,
6812 )
6813 },
6814 |value| {
6815 find_mapped_value_v1(
6816 connection,
6817 "commands",
6818 "commands_map",
6819 "command",
6820 row.manifest_rowid,
6821 value,
6822 query.exact,
6823 )
6824 },
6825 )
6826}
6827
6828fn infer_match_criteria<FTag, FCommand>(
6829 id: &str,
6830 name: &str,
6831 moniker: Option<&str>,
6832 query: &PackageQuery,
6833 semantics: SearchSemantics,
6834 find_tag: FTag,
6835 find_command: FCommand,
6836) -> Result<Option<String>>
6837where
6838 FTag: FnOnce(&str) -> Result<Option<String>>,
6839 FCommand: FnOnce(&str) -> Result<Option<String>>,
6840{
6841 if let Some(value) = &query.tag {
6842 return Ok(Some(format_match_criteria("Tag", value)));
6843 }
6844 if let Some(value) = &query.command {
6845 return Ok(Some(format_match_criteria("Command", value)));
6846 }
6847 if let Some(value) = &query.moniker {
6848 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6849 }
6850 if semantics == SearchSemantics::Single {
6851 return Ok(None);
6852 }
6853 if let Some(value) = &query.query {
6854 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6855 return Ok(None);
6856 }
6857 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6858 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6859 }
6860 if let Some(tag) = find_tag(value)? {
6861 return Ok(Some(format_match_criteria("Tag", &tag)));
6862 }
6863 if let Some(command) = find_command(value)? {
6864 return Ok(Some(format_match_criteria("Command", &command)));
6865 }
6866 }
6867 Ok(None)
6868}
6869
6870fn find_mapped_value_v2(
6871 connection: &Connection,
6872 table_name: &str,
6873 map_table_name: &str,
6874 value_name: &str,
6875 package_rowid: i64,
6876 query: &str,
6877 exact: bool,
6878) -> Result<Option<String>> {
6879 let sql = format!(
6880 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6881 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6882 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6883 );
6884 let values = query_rows(
6885 connection,
6886 &sql,
6887 vec![
6888 SqlValue::Integer(package_rowid),
6889 SqlValue::Text(match_parameter(query, exact)),
6890 ],
6891 |row| row_string(row, 0),
6892 )?;
6893 Ok(select_best_text_match(values, query, exact))
6894}
6895
6896fn find_mapped_value_v1(
6897 connection: &Connection,
6898 table_name: &str,
6899 map_table_name: &str,
6900 value_name: &str,
6901 manifest_rowid: i64,
6902 query: &str,
6903 exact: bool,
6904) -> Result<Option<String>> {
6905 let sql = format!(
6906 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6907 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6908 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6909 );
6910 let values = query_rows(
6911 connection,
6912 &sql,
6913 vec![
6914 SqlValue::Integer(manifest_rowid),
6915 SqlValue::Text(match_parameter(query, exact)),
6916 ],
6917 |row| row_string(row, 0),
6918 )?;
6919 Ok(select_best_text_match(values, query, exact))
6920}
6921
6922fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6923 if let Some(value) = &query.tag {
6924 return Some(format_match_criteria("Tag", value));
6925 }
6926 if let Some(value) = &query.command {
6927 return Some(format_match_criteria("Command", value));
6928 }
6929 if let Some(value) = &query.moniker {
6930 return Some(format_match_criteria("Moniker", value));
6931 }
6932 if semantics == SearchSemantics::Single {
6933 return None;
6934 }
6935 let value = query.query.as_deref()?;
6936 if json_string(item, "PackageIdentifier")
6937 .as_deref()
6938 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6939 || json_string(item, "PackageName")
6940 .as_deref()
6941 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6942 {
6943 return None;
6944 }
6945 json_string(item, "Moniker")
6946 .filter(|candidate| matches_text(candidate, value, query.exact))
6947 .map(|candidate| format_match_criteria("Moniker", &candidate))
6948}
6949
6950fn format_match_criteria(field: &str, value: &str) -> String {
6951 format!("{field}: {value}")
6952}
6953
6954fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6955 if exact {
6956 candidate.eq_ignore_ascii_case(query)
6957 } else {
6958 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6959 }
6960}
6961
6962fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6963 let mut score = 0;
6964
6965 if let Some(value) = &query.query {
6966 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6967 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6968 if let Some(moniker) = candidate.moniker.as_deref() {
6969 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6970 }
6971 }
6972
6973 if let Some(value) = &query.id {
6974 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6975 }
6976 if let Some(value) = &query.name {
6977 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6978 }
6979 if let Some(value) = &query.moniker
6980 && let Some(moniker) = candidate.moniker.as_deref()
6981 {
6982 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6983 }
6984 if let Some(value) = &query.tag
6985 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6986 {
6987 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6988 }
6989 if let Some(value) = &query.command
6990 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6991 {
6992 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6993 }
6994
6995 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6996 score += 5;
6997 }
6998 if search_match_has_unknown_version(candidate) {
6999 score = score.saturating_sub(10);
7000 }
7001
7002 score
7003}
7004
7005fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
7006 candidate
7007 .version
7008 .as_deref()
7009 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
7010}
7011
7012fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
7021 let mut document = match documents {
7022 JsonValue::Object(map) => map.clone(),
7023 _ => serde_json::Map::new(),
7024 };
7025
7026 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7027 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7028 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7029 if !manifest.channel.is_empty() {
7030 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7031 } else {
7032 document.remove("Channel");
7033 }
7034
7035 let mut single_installer = serde_json::Map::new();
7038 if let Some(arch) = installer.architecture.as_ref() {
7039 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7040 }
7041 if let Some(installer_type) = installer.installer_type.as_ref() {
7042 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7043 }
7044 if let Some(url) = installer.url.as_ref() {
7045 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7046 }
7047 if let Some(sha) = installer.sha256.as_ref() {
7048 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7049 }
7050 if let Some(scope) = installer.scope.as_ref() {
7051 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7052 }
7053 if let Some(locale) = installer.locale.as_ref() {
7054 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7055 }
7056 if let Some(product_code) = installer.product_code.as_ref() {
7057 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7058 }
7059 if let Some(release_date) = installer.release_date.as_ref() {
7060 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7061 }
7062
7063 document.insert(
7064 "Installers".to_owned(),
7065 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7066 );
7067 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7068
7069 let value = JsonValue::Object(document);
7070 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7071}
7072
7073fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7078 let mut filename = derive_download_filename_stem(manifest, installer);
7079 filename.push_str(".yaml");
7080 filename
7081}
7082
7083fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7084 let mut stem = sanitize_filename_component(&manifest.name);
7085 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7086 stem.push('_');
7087 stem.push_str(&sanitize_filename_component(&manifest.version));
7088 }
7089 if let Some(scope) = installer.scope.as_deref() {
7090 stem.push('_');
7091 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7092 }
7093 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7094 stem.push('_');
7095 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7096
7097 let installer_type = installer.installer_type.as_deref().unwrap_or("");
7098 if !installer_type.is_empty() {
7099 stem.push('_');
7100 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7101 }
7102 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7103 stem.push('_');
7104 stem.push_str(&sanitize_filename_component(locale));
7105 }
7106
7107 stem
7108}
7109
7110fn capitalize_first(value: &str) -> String {
7111 let mut chars = value.chars();
7112 match chars.next() {
7113 Some(first) => {
7114 let mut out = first.to_ascii_uppercase().to_string();
7115 out.push_str(&chars.as_str().to_ascii_lowercase());
7116 out
7117 }
7118 None => String::new(),
7119 }
7120}
7121
7122fn format_architecture(value: &str) -> String {
7123 match value.to_ascii_lowercase().as_str() {
7124 "x64" => "X64".to_owned(),
7125 "x86" => "X86".to_owned(),
7126 "arm" => "Arm".to_owned(),
7127 "arm64" => "Arm64".to_owned(),
7128 "neutral" => "Neutral".to_owned(),
7129 other => capitalize_first(other),
7130 }
7131}
7132
7133fn sanitize_filename_component(value: &str) -> String {
7134 value
7135 .chars()
7136 .map(|ch| match ch {
7137 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7138 ch if ch.is_control() => '_',
7139 ch => ch,
7140 })
7141 .collect()
7142}
7143
7144fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7145 if manifest_path.is_file() {
7146 return Ok(manifest_path.to_path_buf());
7147 }
7148
7149 if !manifest_path.is_dir() {
7150 bail!("Manifest path not found: {}", manifest_path.display());
7151 }
7152
7153 let candidate = fs::read_dir(manifest_path)?
7154 .filter_map(|entry| entry.ok())
7155 .map(|entry| entry.path())
7156 .filter(|path| {
7157 path.extension()
7158 .and_then(|value| value.to_str())
7159 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7160 })
7161 .min();
7162
7163 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7164}
7165
7166fn score_text_match(
7167 candidate: &str,
7168 query: &str,
7169 exact: bool,
7170 exact_score: usize,
7171 prefix_score: usize,
7172 substring_score: usize,
7173) -> usize {
7174 if candidate.eq_ignore_ascii_case(query) {
7175 return exact_score;
7176 }
7177 if exact {
7178 return 0;
7179 }
7180
7181 let candidate_lower = candidate.to_ascii_lowercase();
7182 let query_lower = query.to_ascii_lowercase();
7183 if candidate_lower.starts_with(&query_lower) {
7184 prefix_score
7185 } else if candidate_lower.contains(&query_lower) {
7186 substring_score
7187 } else {
7188 0
7189 }
7190}
7191
7192fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7193where
7194 I: IntoIterator<Item = String>,
7195{
7196 values
7197 .into_iter()
7198 .filter_map(|candidate| {
7199 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7200 (score > 0).then_some((score, candidate))
7201 })
7202 .max_by(|(left_score, left_value), (right_score, right_value)| {
7203 left_score
7204 .cmp(right_score)
7205 .then_with(|| right_value.len().cmp(&left_value.len()))
7206 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7207 })
7208 .map(|(_, candidate)| candidate)
7209}
7210
7211fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7212 let (field, value) = criteria.split_once(": ")?;
7213 Some((field, value))
7214}
7215
7216fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7217 let versions = item
7218 .get("Versions")
7219 .and_then(JsonValue::as_array)
7220 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7221 let mut result = Vec::new();
7222 for version in versions {
7223 let value = json_string(version, "PackageVersion")
7224 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7225 let channel = json_string(version, "Channel").unwrap_or_default();
7226 result.push(VersionKey {
7227 version: value,
7228 channel,
7229 });
7230 }
7231 Ok(result)
7232}
7233
7234fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7235 let mut merged = YamlMapping::new();
7236 let mut documents = Vec::new();
7237 for document in serde_yaml::Deserializer::from_slice(bytes) {
7238 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7239 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7240 if let Some(mapping) = value.as_mapping() {
7241 merge_yaml_mapping(&mut merged, mapping);
7242 }
7243 }
7244
7245 let id = yaml_string_from_root(&merged, "PackageIdentifier")
7246 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7247 let version =
7248 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7249 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7250
7251 let installers = parse_yaml_installers(&merged);
7252 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7253 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7254
7255 Ok((
7256 Manifest {
7257 id,
7258 name,
7259 version,
7260 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7261 publisher: yaml_localized_string(&merged, "Publisher"),
7262 description: yaml_localized_string(&merged, "Description")
7263 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7264 moniker: yaml_string_from_root(&merged, "Moniker"),
7265 package_url: yaml_localized_string(&merged, "PackageUrl"),
7266 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7267 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7268 license: yaml_localized_string(&merged, "License"),
7269 license_url: yaml_localized_string(&merged, "LicenseUrl"),
7270 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7271 author: yaml_localized_string(&merged, "Author"),
7272 copyright: yaml_localized_string(&merged, "Copyright"),
7273 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7274 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7275 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7276 tags: yaml_string_list(&merged, "Tags"),
7277 agreements: yaml_agreement_list(&merged),
7278 package_dependencies: yaml_package_dependencies(&merged),
7279 documentation: yaml_documentation_list(&merged),
7280 icons: yaml_icon_list(&merged),
7281 installers,
7282 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7283 },
7284 collapse_structured_document(&JsonValue::Array(documents)),
7285 ))
7286}
7287
7288fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7289 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7290}
7291
7292fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7293 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7294 let data = root
7295 .get("Data")
7296 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7297 let versions = data
7298 .get("Versions")
7299 .and_then(JsonValue::as_array)
7300 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7301 let selected = versions
7302 .iter()
7303 .find(|item| {
7304 json_string(item, "PackageVersion").as_deref() == Some(version)
7305 && json_string(item, "Channel").unwrap_or_default() == channel
7306 })
7307 .or_else(|| versions.first())
7308 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7309
7310 let default_locale = selected
7311 .get("DefaultLocale")
7312 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7313 let package_default_locale = data.get("DefaultLocale");
7314 let name = json_string(default_locale, "PackageName")
7315 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7316 let installer_switch_defaults = json_installer_switches(selected);
7317 let top_platforms = json_string_list(selected, "Platform");
7318 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7319 let top_installer_type = json_string(selected, "InstallerType");
7320 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7321 let top_nested_installer_files = json_nested_installer_files(selected);
7322
7323 let installers = selected
7324 .get("Installers")
7325 .and_then(JsonValue::as_array)
7326 .map(|items| {
7327 items
7328 .iter()
7329 .map(|item| Installer {
7330 platforms: {
7331 let platforms = json_string_list(item, "Platform");
7332 if platforms.is_empty() {
7333 top_platforms.clone()
7334 } else {
7335 platforms
7336 }
7337 },
7338 minimum_os_version: json_string(item, "MinimumOSVersion")
7339 .or_else(|| top_minimum_os_version.clone()),
7340 architecture: json_string(item, "Architecture"),
7341 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7342 nested_installer_type: json_string(item, "NestedInstallerType")
7343 .or_else(|| top_nested_installer_type.clone()),
7344 nested_installer_files: {
7345 let files = json_nested_installer_files(item);
7346 if files.is_empty() {
7347 top_nested_installer_files.clone()
7348 } else {
7349 files
7350 }
7351 },
7352 url: json_string(item, "InstallerUrl"),
7353 sha256: json_string(item, "InstallerSha256"),
7354 product_code: json_string(item, "ProductCode"),
7355 locale: json_string(item, "InstallerLocale"),
7356 scope: json_string(item, "Scope"),
7357 release_date: json_string(item, "ReleaseDate"),
7358 package_family_name: json_string(item, "PackageFamilyName"),
7359 upgrade_code: json_string(item, "UpgradeCode"),
7360 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7361 commands: json_string_list(item, "Commands"),
7362 package_dependencies: json_package_dependencies(item),
7363 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7364 })
7365 .collect::<Vec<_>>()
7366 })
7367 .unwrap_or_default();
7368 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7369 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7370 let mut icons = json_icon_list(default_locale);
7371 if icons.is_empty() {
7372 icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7373 }
7374
7375 let manifest = Manifest {
7376 id: package_id.to_owned(),
7377 name,
7378 version: version.to_owned(),
7379 channel: channel.to_owned(),
7380 publisher: json_string(default_locale, "Publisher"),
7381 description: json_string(default_locale, "Description")
7382 .or_else(|| json_string(default_locale, "ShortDescription")),
7383 moniker: json_string(default_locale, "Moniker"),
7384 package_url: json_string(default_locale, "PackageUrl"),
7385 publisher_url: json_string(default_locale, "PublisherUrl"),
7386 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7387 license: json_string(default_locale, "License"),
7388 license_url: json_string(default_locale, "LicenseUrl"),
7389 privacy_url: json_string(default_locale, "PrivacyUrl"),
7390 author: json_string(default_locale, "Author"),
7391 copyright: json_string(default_locale, "Copyright"),
7392 copyright_url: json_string(default_locale, "CopyrightUrl"),
7393 release_notes: json_string(default_locale, "ReleaseNotes"),
7394 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7395 tags: json_string_list(default_locale, "Tags"),
7396 agreements: json_agreement_list(default_locale),
7397 package_dependencies: json_package_dependencies(selected),
7398 documentation: json_documentation_list(default_locale),
7399 icons,
7400 installers,
7401 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7402 };
7403
7404 Ok((
7405 manifest,
7406 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7407 ))
7408}
7409
7410fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7411 match document {
7412 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7413 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7414 }
7415 JsonValue::Array(documents) => merge_manifest_documents(documents),
7416 JsonValue::Object(_) if is_merged_manifest_document(document) => {
7417 merge_manifest_documents(&split_merged_manifest_document(document))
7418 }
7419 _ => document.clone(),
7420 }
7421}
7422
7423fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7424 documents.iter().map(collapse_structured_document).collect()
7425}
7426
7427fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7428 let Some(root) = document.as_object_mut() else {
7429 return document;
7430 };
7431
7432 root.insert(
7433 "IconUrl".to_owned(),
7434 manifest
7435 .icon_url()
7436 .map(|url| JsonValue::String(url.to_owned()))
7437 .unwrap_or(JsonValue::Null),
7438 );
7439 root.insert(
7440 "Icon".to_owned(),
7441 manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7442 );
7443 root.entry("Icons".to_owned())
7444 .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7445
7446 document
7447}
7448
7449fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7450 serde_json::to_value(icon).expect("package icon serialization cannot fail")
7451}
7452
7453fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7454 let version = documents.iter().find(|document| {
7455 document
7456 .get("ManifestType")
7457 .and_then(JsonValue::as_str)
7458 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7459 });
7460 let default_locale = documents.iter().find(|document| {
7461 document
7462 .get("ManifestType")
7463 .and_then(JsonValue::as_str)
7464 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7465 });
7466 let installer = documents.iter().find(|document| {
7467 document
7468 .get("ManifestType")
7469 .and_then(JsonValue::as_str)
7470 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7471 });
7472
7473 let mut singleton = JsonMap::new();
7474 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7475 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7476 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7477
7478 if !singleton.contains_key("PackageLocale")
7479 && let Some(package_locale) = default_locale
7480 .and_then(|document| json_string(document, "PackageLocale"))
7481 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7482 {
7483 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7484 }
7485
7486 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7487 singleton.insert(
7488 "ManifestVersion".to_owned(),
7489 JsonValue::String(
7490 installer
7491 .and_then(|document| json_string(document, "ManifestVersion"))
7492 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7493 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7494 .unwrap_or_else(|| "1.10.0".to_owned()),
7495 ),
7496 );
7497 singleton.remove("DefaultLocale");
7498
7499 JsonValue::Object(singleton)
7500}
7501
7502fn is_merged_manifest_document(document: &JsonValue) -> bool {
7503 document
7504 .get("ManifestType")
7505 .and_then(JsonValue::as_str)
7506 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7507}
7508
7509fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7510 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7511 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7512 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7513 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7514
7515 let version_document = serde_json::json!({
7516 "PackageIdentifier": package_identifier,
7517 "PackageVersion": package_version,
7518 "DefaultLocale": package_locale,
7519 "ManifestType": "version",
7520 "ManifestVersion": manifest_version,
7521 });
7522
7523 let mut default_locale_document = project_manifest_document(
7524 merged,
7525 &[
7526 "PackageIdentifier",
7527 "PackageVersion",
7528 "PackageLocale",
7529 "Publisher",
7530 "PublisherUrl",
7531 "PublisherSupportUrl",
7532 "PrivacyUrl",
7533 "Author",
7534 "PackageName",
7535 "PackageUrl",
7536 "License",
7537 "LicenseUrl",
7538 "Copyright",
7539 "CopyrightUrl",
7540 "ShortDescription",
7541 "Description",
7542 "Moniker",
7543 "Tags",
7544 "Agreements",
7545 "ReleaseNotes",
7546 "ReleaseNotesUrl",
7547 "PurchaseUrl",
7548 "InstallationNotes",
7549 "Documentations",
7550 "Icons",
7551 ],
7552 );
7553 default_locale_document.insert(
7554 "PackageIdentifier".to_owned(),
7555 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7556 );
7557 default_locale_document.insert(
7558 "PackageVersion".to_owned(),
7559 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7560 );
7561 default_locale_document.insert(
7562 "PackageLocale".to_owned(),
7563 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7564 );
7565 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7566 default_locale_document.insert(
7567 "ManifestVersion".to_owned(),
7568 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7569 );
7570
7571 let mut installer_document = project_manifest_document(
7572 merged,
7573 &[
7574 "PackageIdentifier",
7575 "PackageVersion",
7576 "Channel",
7577 "InstallerLocale",
7578 "Platform",
7579 "MinimumOSVersion",
7580 "InstallerType",
7581 "NestedInstallerType",
7582 "NestedInstallerFiles",
7583 "Scope",
7584 "InstallModes",
7585 "InstallerSwitches",
7586 "InstallerSuccessCodes",
7587 "ExpectedReturnCodes",
7588 "UpgradeBehavior",
7589 "Commands",
7590 "Protocols",
7591 "FileExtensions",
7592 "Dependencies",
7593 "PackageFamilyName",
7594 "ProductCode",
7595 "Capabilities",
7596 "RestrictedCapabilities",
7597 "Markets",
7598 "InstallerAbortsTerminal",
7599 "ReleaseDate",
7600 "InstallLocationRequired",
7601 "RequireExplicitUpgrade",
7602 "DisplayInstallWarnings",
7603 "UnsupportedOSArchitectures",
7604 "UnsupportedArguments",
7605 "AppsAndFeaturesEntries",
7606 "ElevationRequirement",
7607 "InstallationMetadata",
7608 "DownloadCommandProhibited",
7609 "RepairBehavior",
7610 "ArchiveBinariesDependOnPath",
7611 "Authentication",
7612 "Installers",
7613 ],
7614 );
7615 installer_document.insert(
7616 "PackageIdentifier".to_owned(),
7617 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7618 );
7619 installer_document.insert(
7620 "PackageVersion".to_owned(),
7621 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7622 );
7623 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7624 installer_document.insert(
7625 "ManifestVersion".to_owned(),
7626 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7627 );
7628
7629 vec![
7630 version_document,
7631 JsonValue::Object(default_locale_document),
7632 JsonValue::Object(installer_document),
7633 ]
7634}
7635
7636fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7637 let mut result = JsonMap::new();
7638 if let Some(source) = source.as_object() {
7639 for key in keys {
7640 if let Some(value) = source.get(*key) {
7641 result.insert((*key).to_owned(), value.clone());
7642 }
7643 }
7644 }
7645
7646 result
7647}
7648
7649fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7650 if let Some(source) = source.and_then(JsonValue::as_object) {
7651 for key in keys {
7652 if let Some(value) = source.get(*key) {
7653 target.insert((*key).to_owned(), value.clone());
7654 }
7655 }
7656 }
7657}
7658
7659fn copy_all_manifest_keys_except(
7660 target: &mut JsonMap<String, JsonValue>,
7661 source: Option<&JsonValue>,
7662 excluded_keys: &[&str],
7663) {
7664 if let Some(source) = source.and_then(JsonValue::as_object) {
7665 for (key, value) in source {
7666 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7667 target.insert(key.clone(), value.clone());
7668 }
7669 }
7670 }
7671}
7672
7673fn build_rest_manifest_documents(
7674 data: &JsonValue,
7675 installer_source: &JsonValue,
7676 default_locale: &JsonValue,
7677 package_id: &str,
7678 version: &str,
7679 channel: &str,
7680) -> JsonValue {
7681 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7682 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7683 let manifest_version = json_string(installer_source, "ManifestVersion")
7684 .or_else(|| json_string(default_locale, "ManifestVersion"))
7685 .or_else(|| json_string(data, "ManifestVersion"))
7686 .unwrap_or_else(|| "1.10.0".to_owned());
7687
7688 let mut version_document = JsonMap::new();
7689 version_document.insert(
7690 "PackageIdentifier".to_owned(),
7691 JsonValue::String(package_identifier.clone()),
7692 );
7693 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7694 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7695 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7696 version_document.insert(
7697 "ManifestVersion".to_owned(),
7698 JsonValue::String(manifest_version.clone()),
7699 );
7700
7701 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7702 default_locale_document.insert(
7703 "PackageIdentifier".to_owned(),
7704 JsonValue::String(package_identifier.clone()),
7705 );
7706 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7707 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7708 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7709 default_locale_document.insert(
7710 "ManifestVersion".to_owned(),
7711 JsonValue::String(manifest_version.clone()),
7712 );
7713
7714 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7715 installer_document.remove("DefaultLocale");
7716 installer_document.remove("Locales");
7717 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7718 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7719 if !channel.is_empty() {
7720 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7721 }
7722 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7723 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7724
7725 let documents = vec![
7726 JsonValue::Object(version_document),
7727 JsonValue::Object(default_locale_document),
7728 JsonValue::Object(installer_document),
7729 ];
7730
7731 merge_manifest_documents(&documents)
7732}
7733
7734fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7735 let base = installer_defaults(root);
7736 let base_switches = yaml_installer_switches(root);
7737 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7738 let installers = items
7739 .iter()
7740 .filter_map(YamlValue::as_mapping)
7741 .map(|item| {
7742 let mut merged = base.clone();
7743 merge_yaml_mapping(&mut merged, item);
7744 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7745 installer_from_yaml(&merged, switches)
7746 })
7747 .collect::<Vec<_>>();
7748 if !installers.is_empty() {
7749 return installers;
7750 }
7751 }
7752
7753 if base.is_empty() {
7754 Vec::new()
7755 } else {
7756 vec![installer_from_yaml(&base, base_switches)]
7757 }
7758}
7759
7760fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7761 let keys = [
7762 "Architecture",
7763 "InstallerType",
7764 "NestedInstallerType",
7765 "NestedInstallerFiles",
7766 "InstallerUrl",
7767 "InstallerSha256",
7768 "ProductCode",
7769 "InstallerLocale",
7770 "Platform",
7771 "MinimumOSVersion",
7772 "Scope",
7773 "ReleaseDate",
7774 "PackageFamilyName",
7775 "UpgradeCode",
7776 "Commands",
7777 ];
7778 let mut defaults = YamlMapping::new();
7779 for key in keys {
7780 if let Some(value) = root.get(YamlValue::from(key)) {
7781 defaults.insert(YamlValue::from(key), value.clone());
7782 }
7783 }
7784 defaults
7785}
7786
7787fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7788 Installer {
7789 architecture: yaml_string(root, "Architecture"),
7790 installer_type: yaml_string(root, "InstallerType"),
7791 nested_installer_type: yaml_string(root, "NestedInstallerType"),
7792 nested_installer_files: yaml_nested_installer_files(root),
7793 url: yaml_string(root, "InstallerUrl"),
7794 sha256: yaml_string(root, "InstallerSha256"),
7795 product_code: yaml_string(root, "ProductCode"),
7796 locale: yaml_string(root, "InstallerLocale"),
7797 scope: yaml_string(root, "Scope"),
7798 release_date: yaml_string(root, "ReleaseDate"),
7799 package_family_name: yaml_string(root, "PackageFamilyName"),
7800 upgrade_code: yaml_string(root, "UpgradeCode"),
7801 platforms: yaml_string_list(root, "Platform"),
7802 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
7803 switches,
7804 commands: yaml_string_list(root, "Commands"),
7805 package_dependencies: yaml_package_dependencies(root),
7806 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
7807 }
7808}
7809
7810fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
7813 let Some(value) = root.get(YamlValue::from(key)) else {
7814 return false;
7815 };
7816 match value {
7817 YamlValue::Bool(b) => *b,
7818 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
7819 _ => false,
7820 }
7821}
7822
7823fn json_bool(value: &JsonValue, key: &str) -> bool {
7824 match value.get(key) {
7825 Some(JsonValue::Bool(b)) => *b,
7826 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
7827 _ => false,
7828 }
7829}
7830
7831fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7832 let mapping = root
7833 .get(YamlValue::from("InstallerSwitches"))
7834 .or_else(|| root.get(YamlValue::from("Switches")))
7835 .and_then(YamlValue::as_mapping);
7836 yaml_installer_switches_from_mapping(mapping)
7837}
7838
7839fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7840 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7841
7842 InstallerSwitches {
7843 silent: string("Silent"),
7844 silent_with_progress: string("SilentWithProgress"),
7845 interactive: string("Interactive"),
7846 custom: string("Custom"),
7847 log: string("Log"),
7848 install_location: string("InstallLocation"),
7849 }
7850}
7851
7852fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7853 yaml_string(root, key).or_else(|| {
7854 root.get(YamlValue::from("DefaultLocale"))
7855 .and_then(YamlValue::as_mapping)
7856 .and_then(|mapping| yaml_string(mapping, key))
7857 })
7858}
7859
7860fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7861 yaml_string(root, key)
7862}
7863
7864fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7865 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7866}
7867
7868fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7869 match value {
7870 YamlValue::Null => None,
7871 YamlValue::Bool(value) => Some(value.to_string()),
7872 YamlValue::Number(value) => Some(value.to_string()),
7873 YamlValue::String(value) => Some(value.clone()),
7874 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7875 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7876 }
7877}
7878
7879fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7880 root.get(YamlValue::from(key))
7881 .and_then(YamlValue::as_sequence)
7882 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7883 .unwrap_or_default()
7884}
7885
7886fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7887 root.get(YamlValue::from("Documentations"))
7888 .and_then(YamlValue::as_sequence)
7889 .map(|items| {
7890 items
7891 .iter()
7892 .filter_map(YamlValue::as_mapping)
7893 .filter_map(|item| {
7894 let url = yaml_string(item, "DocumentUrl")?;
7895 Some(Documentation {
7896 label: yaml_string(item, "DocumentLabel"),
7897 url,
7898 })
7899 })
7900 .collect()
7901 })
7902 .unwrap_or_default()
7903}
7904
7905fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7906 root.get(YamlValue::from("Icons"))
7907 .and_then(YamlValue::as_sequence)
7908 .map(|items| {
7909 items
7910 .iter()
7911 .filter_map(YamlValue::as_mapping)
7912 .filter_map(|item| {
7913 let icon = PackageIcon {
7914 icon_url: yaml_string(item, "IconUrl"),
7915 icon_file_type: yaml_string(item, "IconFileType"),
7916 icon_resolution: yaml_string(item, "IconResolution"),
7917 icon_theme: yaml_string(item, "IconTheme"),
7918 icon_sha256: yaml_string(item, "IconSha256"),
7919 };
7920 (!icon.is_empty()).then_some(icon)
7921 })
7922 .collect()
7923 })
7924 .unwrap_or_default()
7925}
7926
7927fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7928 root.get(YamlValue::from("Agreements"))
7929 .and_then(YamlValue::as_sequence)
7930 .map(|items| {
7931 items
7932 .iter()
7933 .filter_map(YamlValue::as_mapping)
7934 .map(|item| PackageAgreement {
7935 label: yaml_string(item, "AgreementLabel"),
7936 text: yaml_string(item, "Agreement"),
7937 url: yaml_string(item, "AgreementUrl"),
7938 })
7939 .collect()
7940 })
7941 .unwrap_or_default()
7942}
7943
7944fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7945 root.get(YamlValue::from("Dependencies"))
7946 .and_then(YamlValue::as_mapping)
7947 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7948 .and_then(YamlValue::as_sequence)
7949 .map(|items| {
7950 items
7951 .iter()
7952 .filter_map(YamlValue::as_mapping)
7953 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7954 .collect()
7955 })
7956 .unwrap_or_default()
7957}
7958
7959fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7960 root.get(YamlValue::from("NestedInstallerFiles"))
7961 .and_then(YamlValue::as_sequence)
7962 .map(|items| {
7963 items
7964 .iter()
7965 .filter_map(YamlValue::as_mapping)
7966 .filter_map(|item| {
7967 let relative = yaml_string(item, "RelativeFilePath")?;
7968 Some(NestedInstallerFile {
7969 relative_file_path: relative,
7970 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7971 })
7972 })
7973 .collect()
7974 })
7975 .unwrap_or_default()
7976}
7977
7978fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7979 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7980}
7981
7982fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7983 value
7984 .get(key)
7985 .and_then(JsonValue::as_array)
7986 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7987 .unwrap_or_default()
7988}
7989
7990fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7991 value
7992 .get("Documentations")
7993 .and_then(JsonValue::as_array)
7994 .map(|items| {
7995 items
7996 .iter()
7997 .filter_map(|item| {
7998 let url = json_string(item, "DocumentUrl")?;
7999 Some(Documentation {
8000 label: json_string(item, "DocumentLabel"),
8001 url,
8002 })
8003 })
8004 .collect()
8005 })
8006 .unwrap_or_default()
8007}
8008
8009fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
8010 value
8011 .get("Icons")
8012 .and_then(JsonValue::as_array)
8013 .map(|items| {
8014 items
8015 .iter()
8016 .filter_map(|item| {
8017 let icon = PackageIcon {
8018 icon_url: json_string(item, "IconUrl"),
8019 icon_file_type: json_string(item, "IconFileType"),
8020 icon_resolution: json_string(item, "IconResolution"),
8021 icon_theme: json_string(item, "IconTheme"),
8022 icon_sha256: json_string(item, "IconSha256"),
8023 };
8024 (!icon.is_empty()).then_some(icon)
8025 })
8026 .collect()
8027 })
8028 .unwrap_or_default()
8029}
8030
8031fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8032 value
8033 .get("Agreements")
8034 .and_then(JsonValue::as_array)
8035 .map(|items| {
8036 items
8037 .iter()
8038 .map(|item| PackageAgreement {
8039 label: json_string(item, "AgreementLabel"),
8040 text: json_string(item, "Agreement"),
8041 url: json_string(item, "AgreementUrl"),
8042 })
8043 .collect()
8044 })
8045 .unwrap_or_default()
8046}
8047
8048fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8049 value
8050 .get("Dependencies")
8051 .and_then(JsonValue::as_object)
8052 .and_then(|deps| deps.get("PackageDependencies"))
8053 .and_then(JsonValue::as_array)
8054 .map(|items| {
8055 items
8056 .iter()
8057 .filter_map(|item| json_string(item, "PackageIdentifier"))
8058 .collect()
8059 })
8060 .unwrap_or_default()
8061}
8062
8063fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8064 value
8065 .get("NestedInstallerFiles")
8066 .and_then(JsonValue::as_array)
8067 .map(|items| {
8068 items
8069 .iter()
8070 .filter_map(|item| {
8071 let relative = json_string(item, "RelativeFilePath")?;
8072 Some(NestedInstallerFile {
8073 relative_file_path: relative,
8074 portable_command_alias: json_string(item, "PortableCommandAlias"),
8075 })
8076 })
8077 .collect()
8078 })
8079 .unwrap_or_default()
8080}
8081
8082fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8083 let switches = value
8084 .get("InstallerSwitches")
8085 .or_else(|| value.get("Switches"))
8086 .and_then(JsonValue::as_object);
8087
8088 let string = |key: &str| {
8089 switches
8090 .and_then(|switches| switches.get(key))
8091 .and_then(JsonValue::as_str)
8092 .map(str::to_owned)
8093 };
8094
8095 InstallerSwitches {
8096 silent: string("Silent"),
8097 silent_with_progress: string("SilentWithProgress"),
8098 interactive: string("Interactive"),
8099 custom: string("Custom"),
8100 log: string("Log"),
8101 install_location: string("InstallLocation"),
8102 }
8103}
8104
8105fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8106 for (key, value) in source {
8107 target.insert(key.clone(), value.clone());
8108 }
8109}
8110
8111fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8112 let reader = Cursor::new(bytes.to_vec());
8113 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8114 let mut file = archive
8115 .by_name(member_name)
8116 .with_context(|| format!("zip payload missing {member_name}"))?;
8117 let mut output = Vec::new();
8118 file.read_to_end(&mut output)
8119 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8120 Ok(output)
8121}
8122
8123fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8124 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8125 return Ok(output);
8126 }
8127
8128 if !bytes.starts_with(b"CK")
8129 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8130 {
8131 return decompress_mszyml_payload(&bytes[offset + 2..]);
8132 }
8133
8134 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8135}
8136
8137fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8138 let mut decoder = flate2::read::DeflateDecoder::new(payload);
8139 let mut output = String::new();
8140 decoder.read_to_string(&mut output)?;
8141 Ok(output)
8142}
8143
8144fn cache_control_max_age(response: &Response) -> u64 {
8145 response
8146 .headers()
8147 .get(reqwest::header::CACHE_CONTROL)
8148 .and_then(|value| value.to_str().ok())
8149 .and_then(|header| {
8150 header.split(',').find_map(|part| {
8151 let trimmed = part.trim();
8152 trimmed
8153 .strip_prefix("max-age=")
8154 .and_then(|value| value.parse::<u64>().ok())
8155 })
8156 })
8157 .unwrap_or(60)
8158}
8159
8160fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8161 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8162 let (major, minor) = major_minor(candidate);
8163 server_versions.iter().any(|version| {
8164 let (server_major, server_minor) = major_minor(version);
8165 server_major == major && server_minor == minor
8166 })
8167 })
8168}
8169
8170fn major_minor(version: &str) -> (String, String) {
8171 let mut parts = version.split('.');
8172 (
8173 parts.next().unwrap_or_default().to_owned(),
8174 parts.next().unwrap_or_default().to_owned(),
8175 )
8176}
8177
8178fn default_market() -> String {
8179 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8180}
8181
8182fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8183 let requested_locale = query.locale.as_deref();
8184 let requested_architecture = query.installer_architecture.as_deref();
8185 let requested_type = query.installer_type.as_deref();
8186 let requested_scope = query.install_scope.as_deref();
8187 let requested_platform = query.platform.as_deref();
8188 let requested_os_version = query.os_version.as_deref();
8189 let system_architecture = current_architecture();
8190 let system_platform = current_platform();
8191 let current_os_version = current_os_version();
8192
8193 installers
8194 .iter()
8195 .enumerate()
8196 .filter(|(_, installer)| {
8197 installer_matches_requested(
8198 installer,
8199 requested_type,
8200 requested_scope,
8201 requested_platform,
8202 requested_os_version,
8203 system_platform,
8204 current_os_version.as_deref(),
8205 )
8206 })
8207 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8208 .max_by_key(|(index, installer)| {
8209 (
8210 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8211 std::cmp::Reverse(*index),
8212 )
8213 })
8214 .map(|(_, installer)| installer)
8215 .cloned()
8216}
8217
8218fn installer_matches_requested(
8219 installer: &Installer,
8220 requested_type: Option<&str>,
8221 requested_scope: Option<&str>,
8222 requested_platform: Option<&str>,
8223 requested_os_version: Option<&str>,
8224 system_platform: Option<&str>,
8225 current_os_version: Option<&str>,
8226) -> bool {
8227 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8228 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8229 && installer_matches_platform(installer, requested_platform, system_platform)
8230 && installer_matches_os_version(installer, requested_os_version, current_os_version)
8231}
8232
8233fn installer_matches_platform(
8234 installer: &Installer,
8235 requested_platform: Option<&str>,
8236 system_platform: Option<&str>,
8237) -> bool {
8238 if installer.platforms.is_empty() {
8239 return true;
8240 }
8241
8242 let Some(effective_platform) = requested_platform.or(system_platform) else {
8243 return true;
8244 };
8245
8246 installer
8247 .platforms
8248 .iter()
8249 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8250}
8251
8252fn installer_matches_os_version(
8253 installer: &Installer,
8254 requested_os_version: Option<&str>,
8255 current_os_version: Option<&str>,
8256) -> bool {
8257 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8258 return true;
8259 };
8260
8261 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8262 return true;
8263 };
8264
8265 match (
8266 parse_dotted_version(minimum_os_version),
8267 parse_dotted_version(actual_os_version),
8268 ) {
8269 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8270 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8271 }
8272}
8273
8274fn installer_matches_architecture(
8275 installer: &Installer,
8276 requested_architecture: Option<&str>,
8277 system_architecture: &str,
8278) -> bool {
8279 let Some(architecture) = installer.architecture.as_deref() else {
8280 return true;
8281 };
8282
8283 let architecture = architecture.to_ascii_lowercase();
8284 if let Some(requested) = requested_architecture {
8285 return architecture.eq_ignore_ascii_case(requested);
8286 }
8287
8288 preferred_architectures(system_architecture)
8289 .iter()
8290 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8291}
8292
8293fn installer_rank(
8294 installer: &Installer,
8295 requested_locale: Option<&str>,
8296 requested_architecture: Option<&str>,
8297 system_architecture: &str,
8298) -> (i32, i32, i32) {
8299 let architecture_rank = architecture_rank(
8300 installer.architecture.as_deref(),
8301 requested_architecture.unwrap_or(system_architecture),
8302 requested_architecture.is_some(),
8303 system_architecture,
8304 );
8305 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8306 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8307 (architecture_rank, locale_rank, command_rank)
8308}
8309
8310fn architecture_rank(
8311 installer_architecture: Option<&str>,
8312 preferred_architecture: &str,
8313 strict: bool,
8314 system_architecture: &str,
8315) -> i32 {
8316 let Some(value) = installer_architecture else {
8317 return 0;
8318 };
8319 if value.eq_ignore_ascii_case(preferred_architecture) {
8320 return 5;
8321 }
8322 if value.eq_ignore_ascii_case("neutral") {
8323 return 4;
8324 }
8325 if strict {
8326 return -1;
8327 }
8328
8329 preferred_architectures(system_architecture)
8330 .iter()
8331 .rev()
8332 .position(|candidate| value.eq_ignore_ascii_case(candidate))
8333 .and_then(|index| i32::try_from(index).ok())
8334 .and_then(|index| index.checked_add(1))
8335 .unwrap_or(-1)
8336}
8337
8338fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8342 if manifest.installers.is_empty() {
8343 return true;
8344 }
8345 let allowed = preferred_architectures(current_architecture());
8346 manifest.installers.iter().any(|installer| {
8347 let arch = installer.architecture.as_deref().unwrap_or("neutral");
8348 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8349 })
8350}
8351
8352fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8353 match system_architecture {
8354 "arm64" => &["arm64", "neutral", "x64", "x86"],
8355 "x64" => &["x64", "neutral", "x86"],
8356 "x86" => &["x86", "neutral"],
8357 _ => &["neutral"],
8358 }
8359}
8360
8361fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8362 match (installer_locale, requested_locale) {
8363 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8364 (Some(installer), Some(requested))
8365 if installer
8366 .split('-')
8367 .next()
8368 .zip(requested.split('-').next())
8369 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8370 {
8371 2
8372 }
8373 (None, Some(_)) => 1,
8374 (Some(_), None) => 1,
8375 (None, None) => 0,
8376 _ => -1,
8377 }
8378}
8379
8380fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8381 match requested {
8382 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8383 None => true,
8384 }
8385}
8386
8387#[cfg(windows)]
8388fn current_architecture() -> &'static str {
8389 use std::sync::OnceLock;
8390 static CELL: OnceLock<&'static str> = OnceLock::new();
8391 CELL.get_or_init(|| {
8392 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8397 let mut process_machine: u16 = 0;
8398 let mut native_machine: u16 = 0;
8399 let handle = unsafe { GetCurrentProcess() };
8402 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8404 if ok != 0 {
8405 match native_machine {
8406 0xAA64 => return "arm64",
8407 0x8664 => return "x64",
8408 0x014C => return "x86",
8409 _ => {}
8410 }
8411 }
8412 compiled_architecture()
8413 })
8414}
8415
8416#[cfg(not(windows))]
8417fn current_architecture() -> &'static str {
8418 compiled_architecture()
8419}
8420
8421fn compiled_architecture() -> &'static str {
8422 match std::env::consts::ARCH {
8423 "x86_64" => "x64",
8424 "x86" => "x86",
8425 "aarch64" => "arm64",
8426 _ => "neutral",
8427 }
8428}
8429
8430fn current_platform() -> Option<&'static str> {
8431 if cfg!(windows) { Some("Windows.Desktop") } else { None }
8432}
8433
8434#[cfg(windows)]
8435fn current_os_version() -> Option<String> {
8436 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8437 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8438 let build: String = key.get_value("CurrentBuildNumber").ok()?;
8439 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8440 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8441 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8442 Some(format!("{major}.{minor}.{build}.{ubr}"))
8443}
8444
8445#[cfg(not(windows))]
8446fn current_os_version() -> Option<String> {
8447 None
8448}
8449
8450fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8451 let mut parsed = Vec::new();
8452 for component in version.split('.') {
8453 if component.is_empty() {
8454 continue;
8455 }
8456 parsed.push(component.parse().ok()?);
8457 }
8458
8459 if parsed.is_empty() { None } else { Some(parsed) }
8460}
8461
8462fn select_v1_version(
8463 versions: &[V1VersionRow],
8464 requested_version: Option<&str>,
8465 requested_channel: Option<&str>,
8466 package_id: &str,
8467 source_name: &str,
8468) -> Result<V1VersionRow> {
8469 let mut ordered = versions.to_vec();
8470 ordered.sort_by(|left, right| {
8471 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8472 });
8473
8474 if let Some(version) = requested_version {
8475 let channel = requested_channel.unwrap_or_default();
8476 return ordered
8477 .iter()
8478 .find(|candidate| {
8479 candidate.version.eq_ignore_ascii_case(version)
8480 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8481 })
8482 .cloned()
8483 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8484 }
8485
8486 if let Some(channel) = requested_channel {
8487 return ordered
8488 .iter()
8489 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8490 .cloned()
8491 .ok_or_else(|| {
8492 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8493 });
8494 }
8495
8496 ordered
8497 .into_iter()
8498 .next()
8499 .ok_or_else(|| anyhow!("package had no versions"))
8500}
8501
8502fn select_v2_version(
8503 versions: &[PackageVersionDataEntry],
8504 requested_version: Option<&str>,
8505 requested_channel: Option<&str>,
8506 package_id: &str,
8507 source_name: &str,
8508) -> Result<PackageVersionDataEntry> {
8509 let mut ordered = versions.to_vec();
8510 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8511
8512 if let Some(channel) = requested_channel {
8513 bail!(
8514 "Channel '{channel}' cannot be matched for package '{package_id}' in pre-indexed V2 source '{source_name}' because the local source index does not include channel metadata."
8515 );
8516 }
8517
8518 if let Some(version) = requested_version {
8519 return ordered
8520 .iter()
8521 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8522 .cloned()
8523 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8524 }
8525
8526 ordered
8527 .into_iter()
8528 .next()
8529 .ok_or_else(|| anyhow!("package had no V2 versions"))
8530}
8531
8532fn missing_package_version(
8533 package_id: &str,
8534 source_name: &str,
8535 requested_version: &str,
8536 requested_channel: Option<&str>,
8537) -> anyhow::Error {
8538 anyhow!(MissingPackageVersionError {
8539 package_id: package_id.to_owned(),
8540 source_name: source_name.to_owned(),
8541 requested_version: requested_version.to_owned(),
8542 requested_channel: requested_channel.map(str::to_owned),
8543 })
8544}
8545
8546fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8547 if let Some(refresh_error) = refresh.error.as_deref() {
8548 let missing_error = error.to_string();
8549 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8550 }
8551
8552 Err(error)
8553}
8554
8555fn select_rest_version<'a>(
8556 versions: &'a [VersionKey],
8557 requested_version: Option<&str>,
8558 requested_channel: Option<&str>,
8559 package_id: &str,
8560 source_name: &str,
8561) -> Result<&'a VersionKey> {
8562 if let Some(version) = requested_version {
8563 let channel = requested_channel.unwrap_or_default();
8564 return versions
8565 .iter()
8566 .find(|candidate| {
8567 candidate.version.eq_ignore_ascii_case(version)
8568 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8569 })
8570 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8571 }
8572
8573 if let Some(channel) = requested_channel {
8574 return versions
8575 .iter()
8576 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8577 .ok_or_else(|| {
8578 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8579 });
8580 }
8581
8582 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8583}
8584
8585fn sort_versions_desc(versions: &mut [VersionKey]) {
8586 versions.sort_by(|left, right| {
8587 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8588 });
8589}
8590
8591fn compare_version_and_channel(
8592 left_version: &str,
8593 left_channel: &str,
8594 right_version: &str,
8595 right_channel: &str,
8596) -> Ordering {
8597 compare_version(left_version, right_version).then_with(|| {
8598 left_channel
8599 .to_ascii_lowercase()
8600 .cmp(&right_channel.to_ascii_lowercase())
8601 })
8602}
8603
8604fn compare_version(left: &str, right: &str) -> Ordering {
8605 let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8606 let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8607 match (left_less_than, right_less_than) {
8608 (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8609 (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8610 (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8611 _ => {}
8612 }
8613
8614 let left_parts = tokenize_version(left);
8615 let right_parts = tokenize_version(right);
8616 let max_len = left_parts.len().max(right_parts.len());
8617
8618 for index in 0..max_len {
8619 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8620 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8621 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8622
8623 let ordering = if let Some((left_number, right_number)) = numeric {
8624 left_number.cmp(&right_number)
8625 } else {
8626 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8627 };
8628
8629 if ordering != Ordering::Equal {
8630 return ordering;
8631 }
8632 }
8633
8634 Ordering::Equal
8635}
8636
8637fn tokenize_version(value: &str) -> Vec<String> {
8638 let mut parts = Vec::new();
8639 let mut current = String::new();
8640 let mut current_is_digit = None;
8641
8642 for ch in value.chars() {
8643 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8644 if !current.is_empty() {
8645 parts.push(std::mem::take(&mut current));
8646 }
8647 current_is_digit = None;
8648 continue;
8649 }
8650
8651 let is_digit = ch.is_ascii_digit();
8652 match current_is_digit {
8653 Some(previous) if previous != is_digit => {
8654 parts.push(std::mem::take(&mut current));
8655 current.push(ch);
8656 current_is_digit = Some(is_digit);
8657 }
8658 Some(_) => current.push(ch),
8659 None => {
8660 current.push(ch);
8661 current_is_digit = Some(is_digit);
8662 }
8663 }
8664 }
8665
8666 if !current.is_empty() {
8667 parts.push(current);
8668 }
8669
8670 parts
8671}
8672
8673fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8674 if !hash_matches(Some(expected_hash), bytes) {
8675 bail!("downloaded file hash did not match expected SHA256");
8676 }
8677 Ok(())
8678}
8679
8680#[cfg(test)]
8681fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8682 let Some(expected_hash) = expected_hash else {
8683 return Ok(());
8684 };
8685 if ignore_security_hash {
8686 return Ok(());
8687 }
8688
8689 if !hash_matches(Some(expected_hash), bytes) {
8690 let actual = sha256_hex(bytes);
8691 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8692 }
8693 Ok(())
8694}
8695
8696fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8697 let Some(expected_hash) = expected_hash else {
8698 return Ok(());
8699 };
8700 if ignore_security_hash {
8701 return Ok(());
8702 }
8703
8704 if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8705 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8706 }
8707 Ok(())
8708}
8709
8710const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8711const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8712const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8713const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8714const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8715
8716#[derive(Debug, Clone, Serialize, Deserialize)]
8717struct InstallerDownloadMetadata {
8718 url: String,
8719 etag: Option<String>,
8720 last_modified: Option<String>,
8721 total_length: Option<u64>,
8722 offset: u64,
8723}
8724
8725#[derive(Debug)]
8726struct DownloadAttempt {
8727 total_length: Option<u64>,
8728}
8729
8730fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8731 let runtime = tokio::runtime::Builder::new_current_thread()
8732 .enable_all()
8733 .build()
8734 .context("failed to create installer download runtime")?;
8735 runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8736}
8737
8738async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8739 let part_path = sidecar_path(dest, "part");
8740 let metadata_path = sidecar_path(dest, "part.json");
8741 let started_at = Instant::now();
8742 let mut last_error = None;
8743
8744 for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8745 if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8746 break;
8747 }
8748
8749 let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8750 .checked_sub(started_at.elapsed())
8751 .unwrap_or_else(|| StdDuration::from_secs(1));
8752 match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8753 Ok(()) => {
8754 let actual_hash = sha256_file_hex(&part_path)?;
8755 replace_file(&part_path, dest)?;
8756 let _ = fs::remove_file(&metadata_path);
8757 return Ok(actual_hash);
8758 }
8759 Err(error) => {
8760 last_error = Some(error);
8761 }
8762 }
8763 }
8764
8765 Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8766}
8767
8768async fn try_download_installer_to_part(
8769 user_agent: &str,
8770 url: &str,
8771 part_path: &Path,
8772 metadata_path: &Path,
8773 remaining: StdDuration,
8774) -> Result<()> {
8775 let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8776 let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8777 let mut request = reqwest::Client::builder()
8778 .user_agent(user_agent)
8779 .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8780 .timeout(remaining)
8781 .build()
8782 .context("failed to build installer download client")?
8783 .get(url)
8784 .header(ACCEPT_ENCODING, "identity");
8785
8786 if offset > 0 {
8787 request = request.header(RANGE, format!("bytes={offset}-"));
8788 if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8789 request = request.header(IF_RANGE, if_range);
8790 }
8791 }
8792
8793 let mut response = request.send().await.context("failed to download installer")?;
8794 let headers = response.headers().clone();
8795 let status = response.status();
8796 let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
8797 parse_content_range(headers.get(CONTENT_RANGE), offset)?
8798 } else if status == StatusCode::OK {
8799 offset = 0;
8800 DownloadAttempt {
8801 total_length: content_length(&headers),
8802 }
8803 } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
8804 let _ = fs::remove_file(part_path);
8805 let _ = fs::remove_file(metadata_path);
8806 bail!("installer partial download could not be resumed; retrying from the beginning");
8807 } else if status.is_success() {
8808 bail!("unexpected installer download HTTP status {status}");
8809 } else {
8810 bail!("Download failed: HTTP {status}");
8811 };
8812
8813 if offset == 0 {
8814 let _ = fs::remove_file(part_path);
8815 }
8816
8817 let mut file = if offset == 0 {
8818 fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
8819 } else {
8820 OpenOptions::new()
8821 .append(true)
8822 .open(part_path)
8823 .with_context(|| format!("failed to open {}", part_path.display()))?
8824 };
8825
8826 let mut metadata = InstallerDownloadMetadata {
8827 url: url.to_owned(),
8828 etag: strong_etag(&headers),
8829 last_modified: header_string(&headers, LAST_MODIFIED),
8830 total_length: attempt.total_length,
8831 offset,
8832 };
8833 write_installer_download_metadata(metadata_path, &metadata)?;
8834
8835 let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8836 while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
8837 .await
8838 .context("installer download stalled while waiting for data")?
8839 .context("failed to read installer response body")?
8840 {
8841 file.write_all(&chunk)
8842 .with_context(|| format!("failed to write {}", part_path.display()))?;
8843 offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
8844
8845 if offset >= next_metadata_offset {
8846 file.flush()
8847 .with_context(|| format!("failed to flush {}", part_path.display()))?;
8848 metadata.offset = offset;
8849 write_installer_download_metadata(metadata_path, &metadata)?;
8850 next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8851 }
8852 }
8853
8854 file.flush()
8855 .with_context(|| format!("failed to flush {}", part_path.display()))?;
8856 file.sync_all()
8857 .with_context(|| format!("failed to sync {}", part_path.display()))?;
8858
8859 if let Some(total_length) = attempt.total_length
8860 && offset != total_length
8861 {
8862 bail!("installer download ended early: received {offset} of {total_length} bytes");
8863 }
8864
8865 metadata.offset = offset;
8866 metadata.total_length = attempt.total_length;
8867 write_installer_download_metadata(metadata_path, &metadata)?;
8868 Ok(())
8869}
8870
8871fn read_installer_download_metadata(
8872 metadata_path: &Path,
8873 part_path: &Path,
8874 url: &str,
8875) -> Result<Option<InstallerDownloadMetadata>> {
8876 let Some(mut metadata) = fs::read(metadata_path)
8877 .ok()
8878 .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
8879 else {
8880 let _ = fs::remove_file(part_path);
8881 let _ = fs::remove_file(metadata_path);
8882 return Ok(None);
8883 };
8884
8885 if metadata.url != url || metadata.offset == 0 {
8886 let _ = fs::remove_file(part_path);
8887 let _ = fs::remove_file(metadata_path);
8888 return Ok(None);
8889 }
8890
8891 let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
8892 metadata.offset = metadata.offset.min(part_len);
8893 if metadata.offset == 0 {
8894 let _ = fs::remove_file(part_path);
8895 let _ = fs::remove_file(metadata_path);
8896 return Ok(None);
8897 }
8898
8899 OpenOptions::new()
8900 .write(true)
8901 .open(part_path)
8902 .with_context(|| format!("failed to open {}", part_path.display()))?
8903 .set_len(metadata.offset)
8904 .with_context(|| format!("failed to truncate {}", part_path.display()))?;
8905
8906 Ok(Some(metadata))
8907}
8908
8909fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
8910 let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
8911 fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
8912}
8913
8914fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
8915 metadata
8916 .etag
8917 .as_ref()
8918 .filter(|etag| !etag.starts_with("W/"))
8919 .cloned()
8920 .or_else(|| metadata.last_modified.clone())
8921}
8922
8923fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
8924 let value = value
8925 .and_then(|value| value.to_str().ok())
8926 .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
8927 let range = value
8928 .strip_prefix("bytes ")
8929 .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
8930 let (span, total) = range
8931 .split_once('/')
8932 .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
8933 let (start, _) = span
8934 .split_once('-')
8935 .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
8936 let start = start
8937 .parse::<u64>()
8938 .with_context(|| format!("invalid Content-Range start: {value}"))?;
8939 if start != expected_start {
8940 bail!("partial installer response started at {start}, expected {expected_start}");
8941 }
8942 let total_length = if total == "*" {
8943 None
8944 } else {
8945 Some(
8946 total
8947 .parse::<u64>()
8948 .with_context(|| format!("invalid Content-Range total: {value}"))?,
8949 )
8950 };
8951 Ok(DownloadAttempt { total_length })
8952}
8953
8954fn content_length(headers: &HeaderMap) -> Option<u64> {
8955 headers
8956 .get(CONTENT_LENGTH)
8957 .and_then(|value| value.to_str().ok())
8958 .and_then(|value| value.parse::<u64>().ok())
8959}
8960
8961fn strong_etag(headers: &HeaderMap) -> Option<String> {
8962 header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
8963}
8964
8965fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
8966 headers
8967 .get(name)
8968 .and_then(|value| value.to_str().ok())
8969 .map(str::to_owned)
8970}
8971
8972fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
8973 let mut value = path.as_os_str().to_os_string();
8974 value.push(".");
8975 value.push(suffix);
8976 PathBuf::from(value)
8977}
8978
8979fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
8980 expected_hash
8981 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
8982 .unwrap_or(true)
8983}
8984
8985fn sha256_hex(bytes: &[u8]) -> String {
8986 let digest = Sha256::digest(bytes);
8987 sha256_digest_hex(digest)
8988}
8989
8990fn sha256_file_hex(path: &Path) -> Result<String> {
8991 let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
8992 let mut hasher = Sha256::new();
8993 let mut buffer = [0u8; 128 * 1024];
8994 loop {
8995 let read = file
8996 .read(&mut buffer)
8997 .with_context(|| format!("failed to read {}", path.display()))?;
8998 if read == 0 {
8999 break;
9000 }
9001 hasher.update(&buffer[..read]);
9002 }
9003 Ok(sha256_digest_hex(hasher.finalize()))
9004}
9005
9006fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
9007 let digest = digest.as_ref();
9008 let mut output = String::with_capacity(digest.len() * 2);
9009 for byte in digest {
9010 output.push_str(&format!("{byte:02X}"));
9011 }
9012 output
9013}
9014
9015fn pins_db_path(app_root: &Path) -> PathBuf {
9016 if uses_packaged_layout(app_root) {
9017 app_root.join("pinning.db")
9018 } else {
9019 app_root.join("pins.db")
9020 }
9021}
9022
9023fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9024 let mut has_current_column = false;
9025 let mut has_legacy_column = false;
9026
9027 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9028 match column_name.as_str() {
9029 "type" => has_current_column = true,
9030 "pin_type" => has_legacy_column = true,
9031 _ => {}
9032 }
9033 }
9034
9035 Ok(if has_current_column {
9036 Some("type")
9037 } else if has_legacy_column {
9038 Some("pin_type")
9039 } else {
9040 None
9041 })
9042}
9043
9044fn decode_pin_type(pin_type_int: i64) -> PinType {
9045 match pin_type_int {
9046 4 => PinType::Blocking,
9047 3 => PinType::Gating,
9048 _ => PinType::Pinning,
9049 }
9050}
9051
9052#[cfg(windows)]
9053fn dispatch_installer(
9054 installer_path: &Path,
9055 installer_type: &str,
9056 request: &InstallRequest,
9057 manifest: &Manifest,
9058 installer: &Installer,
9059) -> Result<i32> {
9060 match installer_type {
9061 "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9062 installer_path,
9063 &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9064 ),
9065 "msi" | "wix" => shell_execute_installer(
9066 Path::new("msiexec.exe"),
9067 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9068 should_elevate_msi_shell_execute(installer),
9069 ),
9070 "msix" | "appx" => {
9071 use std::process::Command;
9072
9073 let mut cmd = Command::new("powershell");
9074 cmd.arg("-NoProfile")
9075 .arg("-Command")
9076 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9077 let status = cmd.status().context("failed to run Add-AppxPackage")?;
9078 Ok(status.code().unwrap_or(-1))
9079 }
9080 "zip" => {
9081 if installer
9087 .nested_installer_type
9088 .as_deref()
9089 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9090 {
9091 return install_portable(installer_path, request, manifest, installer);
9092 }
9093 let target = dirs::data_local_dir()
9094 .unwrap_or_else(|| PathBuf::from("."))
9095 .join("Programs");
9096 fs::create_dir_all(&target)?;
9097 let file = fs::File::open(installer_path)?;
9098 let mut archive = ZipArchive::new(file)?;
9099 archive.extract(&target)?;
9100 Ok(0)
9101 }
9102 "portable" => install_portable(installer_path, request, manifest, installer),
9103 _ => shell_execute_installer(
9106 installer_path,
9107 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9108 false,
9109 ),
9110 }
9111}
9112
9113#[cfg(windows)]
9114fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9115 request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9116}
9117
9118#[cfg(windows)]
9119fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9120 installer
9121 .scope
9122 .as_deref()
9123 .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9124}
9125
9126#[cfg(windows)]
9127fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9128 use std::ffi::OsStr;
9129
9130 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9131 use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9132 use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9133 use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9134
9135 let file = wide_null(installer_path.as_os_str());
9136 let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9137 let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9138
9139 let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9142 exec_info.cbSize =
9143 u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9144 exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9145 exec_info.lpFile = file.as_ptr();
9146 exec_info.lpParameters = parameters.as_ptr();
9147 if let Some(verb) = verb.as_ref() {
9148 exec_info.lpVerb = verb.as_ptr();
9149 }
9150 exec_info.nShow = SW_SHOW;
9151
9152 if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9155 bail!(
9156 "failed to launch installer through ShellExecute (Win32 error {})",
9157 unsafe { GetLastError() }
9159 );
9160 }
9161
9162 if exec_info.hProcess.is_null() {
9163 bail!("ShellExecute did not return an installer process handle");
9164 }
9165
9166 let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9169 if wait_result != WAIT_OBJECT_0 {
9170 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9172 bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9173 }
9174
9175 let mut exit_code = 0u32;
9176 let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9178 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9180 if got_exit_code == 0 {
9181 let error = unsafe { GetLastError() };
9183 bail!("failed to retrieve installer exit code (Win32 error {error})");
9184 }
9185
9186 Ok(exit_code.cast_signed())
9187}
9188
9189#[cfg(windows)]
9190fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9191 value.encode_wide().chain(std::iter::once(0)).collect()
9192}
9193
9194#[cfg(windows)]
9195fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9196 use std::ffi::OsStr;
9197 use std::ptr::{null, null_mut};
9198
9199 use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9200 INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9201 };
9202
9203 let parsed = parse_msi_direct_arguments(args)?;
9204 let package_path = wide_null(installer_path.as_os_str());
9205 let properties = wide_null(OsStr::new(&parsed.properties));
9206 let property_ptr = if parsed.properties.is_empty() {
9207 null()
9208 } else {
9209 properties.as_ptr()
9210 };
9211 let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9212
9213 if let Some(path) = log_path.as_ref() {
9214 let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9217 if result != 0 {
9218 bail!("failed to enable MSI logging (Win32 error {result})");
9219 }
9220 } else {
9221 let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9223 }
9224
9225 let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9226 (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9227 } else {
9228 parsed.ui_level
9229 };
9230 unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9233 let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9236 Ok(result.cast_signed())
9237}
9238
9239#[cfg(any(windows, test))]
9240#[derive(Debug, PartialEq, Eq)]
9241struct MsiDirectArguments {
9242 ui_level: u32,
9243 properties: String,
9244 log_mode: u32,
9245 log_path: Option<String>,
9246 log_attributes: u32,
9247}
9248
9249#[cfg(any(windows, test))]
9250fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9251 const INSTALLUILEVEL_DEFAULT: u32 = 1;
9252 const INSTALLUILEVEL_NONE: u32 = 2;
9253 const INSTALLUILEVEL_BASIC: u32 = 3;
9254 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9255 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9256 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9257 const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9258 const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9259 const LOGMODE_VERBOSE: u32 = 4096;
9260 const LOGMODE_EXTRADEBUG: u32 = 8192;
9261 const LOGATTR_APPEND: u32 = 1;
9262 const LOGATTR_FLUSH: u32 = 2;
9263
9264 let mut parsed = MsiDirectArguments {
9265 ui_level: INSTALLUILEVEL_DEFAULT,
9266 properties: String::new(),
9267 log_mode: 0,
9268 log_path: None,
9269 log_attributes: 0,
9270 };
9271
9272 let mut index = 0;
9273 while index < args.len() {
9274 let token = &args[index];
9275 if is_msi_switch(token) {
9276 let option = &token[1..];
9277 if option.eq_ignore_ascii_case("quiet") {
9278 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9279 } else if option.eq_ignore_ascii_case("passive") {
9280 parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9281 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9282 } else if option.eq_ignore_ascii_case("norestart") {
9283 append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9284 } else if option.eq_ignore_ascii_case("forcerestart") {
9285 append_msi_property(&mut parsed.properties, "REBOOT=Force");
9286 } else if option.eq_ignore_ascii_case("promptrestart") {
9287 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9288 } else if option.eq_ignore_ascii_case("log") {
9289 index += 1;
9290 let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9291 parsed.log_mode = LOGMODE_ALL;
9292 parsed.log_path = Some(path.clone());
9293 } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9294 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9295 } else if option.starts_with(['q', 'Q']) {
9296 parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9297 } else if option.starts_with(['l', 'L']) {
9298 index += 1;
9299 let path = args
9300 .get(index)
9301 .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9302 let (mode, attributes) = parse_msi_log_mode(
9303 &option[1..],
9304 LOGMODE_DEFAULT,
9305 LOGMODE_ALL,
9306 LOGMODE_VERBOSE,
9307 LOGMODE_EXTRADEBUG,
9308 LOGATTR_APPEND,
9309 LOGATTR_FLUSH,
9310 )?;
9311 parsed.log_mode = mode;
9312 parsed.log_attributes = attributes;
9313 parsed.log_path = Some(path.clone());
9314 } else {
9315 bail!("unsupported MSI switch for direct MSI execution: {token}");
9316 }
9317 } else {
9318 append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9319 }
9320 index += 1;
9321 }
9322
9323 Ok(parsed)
9324}
9325
9326#[cfg(any(windows, test))]
9327fn is_msi_switch(token: &str) -> bool {
9328 token.starts_with('/') || token.starts_with('-')
9329}
9330
9331#[cfg(any(windows, test))]
9332fn append_msi_property(properties: &mut String, property: &str) {
9333 if !properties.is_empty() {
9334 properties.push(' ');
9335 }
9336 properties.push_str(property);
9337}
9338
9339#[cfg(any(windows, test))]
9340fn format_msi_property(token: &str) -> Result<String> {
9341 let Some((name, value)) = token.split_once('=') else {
9342 bail!("invalid MSI property argument: {token}");
9343 };
9344 if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9345 bail!("invalid MSI property name: {name}");
9346 }
9347 if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9348 Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9349 } else {
9350 Ok(token.to_owned())
9351 }
9352}
9353
9354#[cfg(any(windows, test))]
9355fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9356 const INSTALLUILEVEL_NONE: u32 = 2;
9357 const INSTALLUILEVEL_BASIC: u32 = 3;
9358 const INSTALLUILEVEL_REDUCED: u32 = 4;
9359 const INSTALLUILEVEL_FULL: u32 = 5;
9360 const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9361 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9362 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9363 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9364
9365 let modifier = if modifier.is_empty() { "n" } else { modifier };
9366 let mut chars = modifier.chars();
9367 let Some(first) = chars.next() else {
9368 return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9369 };
9370 let mut ui_level = match first.to_ascii_lowercase() {
9371 'f' => INSTALLUILEVEL_FULL,
9372 'r' => INSTALLUILEVEL_REDUCED,
9373 'b' => INSTALLUILEVEL_BASIC,
9374 'n' => INSTALLUILEVEL_NONE,
9375 '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9376 _ => bail!("invalid MSI /q modifier: {modifier}"),
9377 };
9378
9379 for ch in chars {
9380 match ch {
9381 '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9382 '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9383 '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9384 '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9385 _ => bail!("invalid MSI /q modifier: {modifier}"),
9386 }
9387 }
9388
9389 if ui_level & 0xF == INSTALLUILEVEL_NONE {
9390 ui_level |= INSTALLUILEVEL_UACONLY;
9391 }
9392 Ok(ui_level)
9393}
9394
9395#[cfg(any(windows, test))]
9396#[allow(clippy::too_many_arguments)]
9397fn parse_msi_log_mode(
9398 modifier: &str,
9399 default_mode: u32,
9400 all_mode: u32,
9401 verbose: u32,
9402 extra_debug: u32,
9403 append: u32,
9404 flush: u32,
9405) -> Result<(u32, u32)> {
9406 let mut mode = 0;
9407 let mut attributes = 0;
9408 for ch in modifier.chars() {
9409 match ch.to_ascii_lowercase() {
9410 '*' => mode |= all_mode,
9411 'm' => mode |= 1,
9412 'e' => mode |= 2,
9413 'w' => mode |= 4,
9414 'u' => mode |= 8,
9415 'i' => mode |= 16,
9416 'o' => mode |= 128,
9417 'a' => mode |= 256,
9418 'r' => mode |= 512,
9419 'p' => mode |= 1024,
9420 'c' => mode |= 2048,
9421 'v' => mode |= verbose,
9422 'x' => mode |= extra_debug,
9423 '+' => attributes |= append,
9424 '!' => attributes |= flush,
9425 _ => bail!("invalid MSI /l modifier: {modifier}"),
9426 }
9427 }
9428 if mode == 0 {
9429 mode = default_mode;
9430 }
9431 Ok((mode, attributes))
9432}
9433
9434#[cfg(not(windows))]
9435fn dispatch_installer(
9436 _installer_path: &Path,
9437 _installer_type: &str,
9438 _request: &InstallRequest,
9439 _manifest: &Manifest,
9440 _installer: &Installer,
9441) -> Result<i32> {
9442 bail!("Installing packages is only supported on Windows")
9443}
9444
9445#[cfg(test)]
9446fn installer_command_arguments(
9447 installer_type: &str,
9448 request: &InstallRequest,
9449 _manifest: &Manifest,
9450 installer_path: &Path,
9451 installer: &Installer,
9452) -> Vec<String> {
9453 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9454}
9455
9456#[cfg(any(windows, test))]
9457fn installer_direct_msi_arguments(
9458 installer_type: &str,
9459 request: &InstallRequest,
9460 _manifest: &Manifest,
9461 installer_path: &Path,
9462 installer: &Installer,
9463) -> Vec<String> {
9464 installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9465}
9466
9467#[cfg(any(windows, test))]
9468fn installer_shell_execute_arguments(
9469 installer_type: &str,
9470 request: &InstallRequest,
9471 _manifest: &Manifest,
9472 installer_path: &Path,
9473 installer: &Installer,
9474) -> Vec<String> {
9475 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9476}
9477
9478#[cfg(any(windows, test))]
9479fn installer_command_arguments_with_options(
9480 installer_type: &str,
9481 request: &InstallRequest,
9482 installer_path: &Path,
9483 installer: &Installer,
9484 include_msi_install_target: bool,
9485) -> Vec<String> {
9486 if let Some(override_args) = request.override_args.as_deref() {
9487 return split_installer_switches(override_args);
9488 }
9489
9490 let mut args = Vec::new();
9491 match installer_type {
9492 "msi" | "wix" if include_msi_install_target => {
9493 args.push("/i".to_owned());
9494 args.push(installer_path.display().to_string());
9495 }
9496 "msix" | "appx" | "zip" => return Vec::new(),
9497 _ => {}
9498 }
9499
9500 let configured = match request.mode {
9501 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9502 InstallerMode::SilentWithProgress => installer
9503 .switches
9504 .silent_with_progress
9505 .as_deref()
9506 .or(installer.switches.silent.as_deref()),
9507 InstallerMode::Silent => installer
9508 .switches
9509 .silent
9510 .as_deref()
9511 .or(installer.switches.silent_with_progress.as_deref()),
9512 };
9513
9514 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9515 args.extend(split_installer_switches(value));
9516 } else {
9517 args.extend(default_installer_arguments(installer_type, request.mode));
9518 }
9519
9520 append_switches(
9521 &mut args,
9522 resolve_template_switch(
9523 installer.switches.log.as_deref(),
9524 default_log_switch(installer_type),
9525 request.log_path.as_ref().map(|path| path.display().to_string()),
9526 "<LOGPATH>",
9527 ),
9528 );
9529 append_switches(&mut args, installer.switches.custom.clone());
9530 append_switches(&mut args, request.custom.clone());
9531 append_switches(
9532 &mut args,
9533 resolve_template_switch(
9534 installer.switches.install_location.as_deref(),
9535 default_install_location_switch(installer_type),
9536 request.install_location.clone(),
9537 "<INSTALLPATH>",
9538 ),
9539 );
9540 args
9541}
9542
9543#[cfg(any(windows, test))]
9544fn join_windows_arguments(args: &[String]) -> String {
9545 args.iter()
9546 .map(|arg| quote_windows_argument(arg))
9547 .collect::<Vec<_>>()
9548 .join(" ")
9549}
9550
9551#[cfg(any(windows, test))]
9552fn quote_windows_argument(arg: &str) -> String {
9553 if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9554 return arg.to_owned();
9555 }
9556
9557 let mut quoted = String::from("\"");
9558 let mut backslashes = 0usize;
9559 for ch in arg.chars() {
9560 match ch {
9561 '\\' => backslashes += 1,
9562 '"' => {
9563 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9564 quoted.push('"');
9565 backslashes = 0;
9566 }
9567 _ => {
9568 quoted.push_str(&"\\".repeat(backslashes));
9569 backslashes = 0;
9570 quoted.push(ch);
9571 }
9572 }
9573 }
9574 quoted.push_str(&"\\".repeat(backslashes * 2));
9575 quoted.push('"');
9576 quoted
9577}
9578
9579#[cfg(any(windows, test))]
9580fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9581 match mode {
9582 InstallerMode::Interactive => Vec::new(),
9583 InstallerMode::SilentWithProgress => match installer_type {
9584 "inno" => vec![
9585 "/SP-".to_owned(),
9586 "/SILENT".to_owned(),
9587 "/SUPPRESSMSGBOXES".to_owned(),
9588 "/NORESTART".to_owned(),
9589 ],
9590 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9591 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9592 _ => vec!["/SILENT".to_owned()],
9593 },
9594 InstallerMode::Silent => match installer_type {
9595 "inno" => vec![
9596 "/SP-".to_owned(),
9597 "/VERYSILENT".to_owned(),
9598 "/SUPPRESSMSGBOXES".to_owned(),
9599 "/NORESTART".to_owned(),
9600 ],
9601 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9602 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9603 _ => vec!["/S".to_owned()],
9604 },
9605 }
9606}
9607
9608#[cfg(any(windows, test))]
9609fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9610 match installer_type {
9611 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9612 "inno" => Some("/LOG=\"<LOGPATH>\""),
9613 _ => None,
9614 }
9615}
9616
9617#[cfg(any(windows, test))]
9618fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9619 match installer_type {
9620 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9621 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9622 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9623 _ => None,
9624 }
9625}
9626
9627#[cfg(any(windows, test))]
9628fn resolve_template_switch(
9629 manifest_value: Option<&str>,
9630 fallback: Option<&str>,
9631 replacement: Option<String>,
9632 token: &str,
9633) -> Option<String> {
9634 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9635 let replacement = replacement?;
9636 Some(template.replace(token, &replacement))
9637}
9638
9639#[cfg(any(windows, test))]
9640fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9641 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9642 args.extend(split_installer_switches(&value));
9643 }
9644}
9645
9646#[cfg(any(windows, test))]
9647fn split_installer_switches(value: &str) -> Vec<String> {
9648 let mut args = Vec::new();
9649 let mut current = String::new();
9650 let mut in_quotes = false;
9651
9652 for ch in value.chars() {
9653 match ch {
9654 '"' => in_quotes = !in_quotes,
9655 c if c.is_whitespace() && !in_quotes => {
9656 if !current.is_empty() {
9657 args.push(std::mem::take(&mut current));
9658 }
9659 }
9660 _ => current.push(ch),
9661 }
9662 }
9663
9664 if !current.is_empty() {
9665 args.push(current);
9666 }
9667
9668 args
9669}
9670
9671#[cfg(windows)]
9672fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9673 if request.purge && request.preserve {
9674 bail!("--purge and --preserve cannot be used together.");
9675 }
9676
9677 if installed
9678 .installer_category
9679 .as_deref()
9680 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9681 {
9682 return uninstall_portable(installed, request);
9683 }
9684
9685 if (request.purge || request.preserve) && !request.force {
9686 bail!("--purge and --preserve are currently only supported for portable packages.");
9687 }
9688
9689 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9690 return Ok(exit_code);
9691 }
9692
9693 if let Some(exit_code) = try_uninstall_msix(installed)? {
9694 return Ok(exit_code);
9695 }
9696
9697 bail!(
9698 "No uninstall command found for installed package '{}' ({})",
9699 installed.name,
9700 installed.local_id
9701 );
9702}
9703
9704#[cfg(windows)]
9705fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9706 use std::os::windows::process::CommandExt;
9707 use std::process::Command;
9708
9709 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9710 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9711
9712 let arp_paths = [
9713 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9714 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9715 ];
9716
9717 for root in [&hklm, &hkcu] {
9718 for arp_path in &arp_paths {
9719 if let Ok(key) = root.open_subkey(arp_path) {
9720 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9721 if let Ok(subkey) = key.open_subkey(&subkey_name) {
9722 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9723 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9724 if !registry_entry_matches_installed_package(
9725 &subkey_name,
9726 &display_name,
9727 product_code.as_deref(),
9728 installed,
9729 ) {
9730 continue;
9731 }
9732
9733 if let Some(exit_code) =
9734 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9735 {
9736 return Ok(Some(exit_code));
9737 }
9738
9739 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9740 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9741 subkey.get_value("UninstallString").ok()
9742 } else {
9743 quiet_uninstall_cmd
9744 .clone()
9745 .or_else(|| subkey.get_value("UninstallString").ok())
9746 }
9747 .context("No uninstall command found in registry")?;
9748
9749 if is_winget_uninstall_command(&uninstall_cmd)
9750 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9751 {
9752 return Ok(Some(exit_code));
9753 }
9754
9755 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9756 let uninstall_line = build_uninstall_command_with_mode(
9757 &uninstall_cmd,
9758 request.mode,
9759 quiet_uninstall_cmd.is_some(),
9760 log_path.as_deref(),
9761 );
9762 let mut cmd = Command::new("cmd");
9771 cmd.raw_arg("/S /C");
9772 cmd.raw_arg(format!("\"{uninstall_line}\""));
9773 let status = cmd.status().context("failed to run uninstaller")?;
9774 return Ok(Some(status.code().unwrap_or(-1)));
9775 }
9776 }
9777 }
9778 }
9779 }
9780
9781 Ok(None)
9782}
9783
9784#[cfg(windows)]
9785fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9786 use std::process::Command;
9787
9788 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9789 return Ok(None);
9790 }
9791
9792 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9793 let mut cmd = Command::new("powershell");
9794 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9795 package_full_name,
9796 &installed.package_family_names,
9797 ));
9798 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
9799 Ok(Some(status.code().unwrap_or(-1)))
9800}
9801
9802#[cfg(windows)]
9803fn arp_subkey_name(local_id: &str) -> Option<&str> {
9804 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
9805}
9806
9807#[cfg(windows)]
9808fn registry_entry_matches_installed_package(
9809 subkey_name: &str,
9810 display_name: &str,
9811 product_code: Option<&str>,
9812 installed: &ListMatch,
9813) -> bool {
9814 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
9815 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
9816 {
9817 return true;
9818 }
9819
9820 if installed.product_codes.iter().any(|code| {
9821 code.eq_ignore_ascii_case(subkey_name)
9822 || product_code
9823 .map(|value| code.eq_ignore_ascii_case(value))
9824 .unwrap_or(false)
9825 }) {
9826 return true;
9827 }
9828
9829 display_name.eq_ignore_ascii_case(&installed.name)
9830}
9831
9832#[cfg(all(windows, test))]
9833fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
9834 build_uninstall_command_with_mode(
9835 uninstall_cmd,
9836 if silent {
9837 InstallerMode::Silent
9838 } else {
9839 InstallerMode::Interactive
9840 },
9841 has_quiet_command,
9842 None,
9843 )
9844}
9845
9846#[cfg(windows)]
9847fn build_uninstall_command_with_mode(
9848 uninstall_cmd: &str,
9849 mode: InstallerMode,
9850 has_quiet_command: bool,
9851 log_path: Option<&str>,
9852) -> String {
9853 let uninstall_cmd = log_path
9854 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
9855 .unwrap_or_else(|| uninstall_cmd.to_owned());
9856 if mode == InstallerMode::Interactive || has_quiet_command {
9857 return uninstall_cmd;
9858 }
9859
9860 let lower = uninstall_cmd.to_ascii_lowercase();
9861 if is_winget_uninstall_command_lower(&lower) {
9862 return uninstall_cmd;
9863 }
9864 if lower.contains("/quiet")
9865 || lower.contains("/passive")
9866 || lower.contains("/verysilent")
9867 || lower.contains("/silent")
9868 || lower.contains(" /s")
9869 {
9870 return uninstall_cmd;
9871 }
9872
9873 format!("{uninstall_cmd} /S")
9874}
9875
9876#[cfg(windows)]
9877fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9878 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
9879 return Ok(None);
9880 };
9881
9882 let exit_code = run_winget_uninstall(&args)?;
9883 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
9884 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
9885 return Ok(Some(exit_code));
9886 };
9887
9888 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
9889 }
9890
9891 Ok(Some(exit_code))
9892}
9893
9894#[cfg(windows)]
9895fn run_winget_uninstall(args: &[String]) -> Result<i32> {
9896 use std::process::Command;
9897
9898 let status = Command::new("winget")
9899 .args(args)
9900 .status()
9901 .context("failed to run delegated winget uninstall")?;
9902 Ok(status.code().unwrap_or(-1))
9903}
9904
9905#[cfg(windows)]
9906fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
9907 build_winget_uninstall_arguments_with_scope(installed, request, true)
9908}
9909
9910#[cfg(windows)]
9911fn build_winget_uninstall_arguments_with_scope(
9912 installed: &ListMatch,
9913 request: &UninstallRequest,
9914 include_scope: bool,
9915) -> Option<Vec<String>> {
9916 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
9917 return None;
9918 }
9919
9920 let mut args = vec![
9921 "uninstall".to_owned(),
9922 "--id".to_owned(),
9923 installed.id.clone(),
9924 "--exact".to_owned(),
9925 "--disable-interactivity".to_owned(),
9926 ];
9927
9928 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
9929 args.push("--source".to_owned());
9930 args.push(source_name.to_owned());
9931 }
9932
9933 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
9934 args.push("--scope".to_owned());
9935 args.push(scope.to_owned());
9936 }
9937
9938 if request.mode == InstallerMode::Silent {
9939 args.push("--silent".to_owned());
9940 }
9941
9942 Some(args)
9943}
9944
9945#[cfg(windows)]
9946fn is_winget_uninstall_command(command: &str) -> bool {
9947 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
9948}
9949
9950#[cfg(windows)]
9951fn is_winget_uninstall_command_lower(command: &str) -> bool {
9952 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
9953}
9954
9955#[cfg(windows)]
9956fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
9957 let full_name_literal = package_full_name
9958 .map(|value| format!("'{}'", value.replace('\'', "''")))
9959 .unwrap_or_else(|| "$null".to_owned());
9960 let family_names_literal = if package_family_names.is_empty() {
9961 "@()".to_owned()
9962 } else {
9963 format!(
9964 "@({})",
9965 package_family_names
9966 .iter()
9967 .map(|value| format!("'{}'", value.replace('\'', "''")))
9968 .collect::<Vec<_>>()
9969 .join(", ")
9970 )
9971 };
9972
9973 format!(
9974 "$fullName = {full_name_literal}; \
9975 $familyNames = {family_names_literal}; \
9976 $targets = Get-AppxPackage | Where-Object {{ \
9977 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
9978 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
9979 if (-not $targets) {{ exit 1 }}; \
9980 $targets | Remove-AppxPackage"
9981 )
9982}
9983
9984#[cfg(windows)]
9985fn try_run_msi_uninstall(
9986 installed: &ListMatch,
9987 subkey_name: &str,
9988 product_code: Option<&str>,
9989 request: &UninstallRequest,
9990) -> Result<Option<i32>> {
9991 use std::process::Command;
9992
9993 let uninstall_code = installed
9994 .product_codes
9995 .iter()
9996 .map(String::as_str)
9997 .chain(product_code)
9998 .chain(Some(subkey_name))
9999 .find(|value| is_product_code_like(value));
10000
10001 let Some(uninstall_code) = uninstall_code else {
10002 return Ok(None);
10003 };
10004
10005 let mut cmd = Command::new("msiexec");
10006 cmd.arg("/x").arg(uninstall_code);
10007 match request.mode {
10008 InstallerMode::Interactive => {}
10009 InstallerMode::SilentWithProgress => {
10010 cmd.arg("/passive").arg("/norestart");
10011 }
10012 InstallerMode::Silent => {
10013 cmd.arg("/quiet").arg("/norestart");
10014 }
10015 }
10016 if let Some(log_path) = &request.log_path {
10017 cmd.arg("/log").arg(log_path);
10018 }
10019
10020 let status = cmd.status().context("failed to run msiexec uninstall")?;
10021 Ok(Some(status.code().unwrap_or(-1)))
10022}
10023
10024#[cfg(windows)]
10025fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10026 let known_symlink = read_portable_symlink_full_path(installed);
10032
10033 let Some(location) = installed.install_location.as_deref() else {
10034 if request.force {
10035 try_remove_portable_symlinks(installed, &known_symlink, None);
10036 try_remove_portable_registry_entry(installed);
10037 return Ok(0);
10038 }
10039 bail!(
10040 "Portable package '{}' does not expose an install location.",
10041 installed.name
10042 );
10043 };
10044
10045 if request.preserve {
10046 return Ok(0);
10047 }
10048
10049 let path = Path::new(location);
10050 let removed = if path.is_dir() {
10051 fs::remove_dir_all(path)?;
10052 true
10053 } else if path.is_file() {
10054 fs::remove_file(path)?;
10055 true
10056 } else {
10057 false
10058 };
10059
10060 if removed || request.force {
10061 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10064 try_remove_portable_registry_entry(installed);
10065 return Ok(0);
10066 }
10067
10068 bail!("Portable package location not found: {location}")
10069}
10070
10071#[cfg(windows)]
10077fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10078 const ARP_PREFIX: &str = r"ARP\";
10079 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10080 let mut parts = rest.splitn(3, '\\');
10081 let scope = parts.next().unwrap_or("");
10082 let _arch = parts.next();
10083 let subkey_name = parts.next().unwrap_or("");
10084 if subkey_name.is_empty() {
10085 return None;
10086 }
10087 let hive = if scope.eq_ignore_ascii_case("User") {
10088 RegKey::predef(HKEY_CURRENT_USER)
10089 } else {
10090 RegKey::predef(HKEY_LOCAL_MACHINE)
10091 };
10092 let subkey = hive
10093 .open_subkey(format!(
10094 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10095 ))
10096 .ok()?;
10097 return read_reg_string(&subkey, "PortableSymlinkFullPath");
10098 }
10099 None
10100}
10101
10102#[cfg(windows)]
10108fn try_remove_portable_symlinks(
10109 _installed: &ListMatch,
10110 known_symlink: &Option<String>,
10111 install_location: Option<&Path>,
10112) {
10113 if let Some(path) = known_symlink.as_deref() {
10114 let _ = fs::remove_file(path);
10115 }
10116
10117 let Some(install_dir) = install_location else { return };
10122
10123 let Some(local) = dirs::data_local_dir() else { return };
10124 let links_root = local.join("Microsoft").join("WinGet").join("Links");
10125 let Ok(entries) = fs::read_dir(&links_root) else { return };
10126 for entry in entries.flatten() {
10127 let path = entry.path();
10128 let Ok(target) = fs::read_link(&path) else { continue };
10129 if path_starts_with_case_insensitive(&target, install_dir) {
10130 let _ = fs::remove_file(&path);
10131 }
10132 }
10133}
10134
10135#[cfg(windows)]
10142fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10143 let lower_components = |p: &Path| -> Vec<String> {
10144 p.components()
10145 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10146 .collect()
10147 };
10148 let parent_components = lower_components(parent);
10149 let child_components = lower_components(child);
10150 if parent_components.is_empty() || parent_components.len() > child_components.len() {
10151 return false;
10152 }
10153 parent_components
10154 .iter()
10155 .zip(child_components.iter())
10156 .all(|(a, b)| a == b)
10157}
10158
10159#[cfg(windows)]
10166fn try_remove_portable_registry_entry(installed: &ListMatch) {
10167 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10170 let mut parts = rest.splitn(3, '\\');
10172 let scope = parts.next().unwrap_or("");
10173 let _arch = parts.next();
10174 let subkey_name = parts.next().unwrap_or("");
10175 if subkey_name.is_empty() {
10176 return;
10177 }
10178 let is_hkcu = scope.eq_ignore_ascii_case("User");
10179 vec![(
10180 is_hkcu,
10181 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10182 )]
10183 } else {
10184 let mut found = Vec::new();
10186 for (is_hkcu, hive) in [
10187 (true, RegKey::predef(HKEY_CURRENT_USER)),
10188 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10189 ] {
10190 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10191 continue;
10192 };
10193 for name in uninstall.enum_keys().flatten() {
10194 let Ok(subkey) = uninstall.open_subkey(&name) else {
10195 continue;
10196 };
10197 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10198 found.push((
10199 is_hkcu,
10200 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10201 ));
10202 }
10203 }
10204 }
10205 found
10206 };
10207
10208 for (is_hkcu, path) in candidates {
10209 let hive = if is_hkcu {
10210 RegKey::predef(HKEY_CURRENT_USER)
10211 } else {
10212 RegKey::predef(HKEY_LOCAL_MACHINE)
10213 };
10214 let _ = hive.delete_subkey_all(&path);
10215 }
10216}
10217
10218#[cfg(windows)]
10219fn is_product_code_like(value: &str) -> bool {
10220 value.starts_with('{') && value.ends_with('}')
10221}
10222
10223#[cfg(not(windows))]
10224fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10225 bail!("Uninstalling packages is only supported on Windows")
10226}
10227
10228#[cfg(windows)]
10250fn install_portable(
10251 installer_path: &Path,
10252 request: &InstallRequest,
10253 manifest: &Manifest,
10254 installer: &Installer,
10255) -> Result<i32> {
10256 let source_identifier = portable_source_identifier(&request.query);
10257 let existing_entry = read_existing_portable_entry(&manifest.id);
10258 let subkey_name = existing_entry
10261 .as_ref()
10262 .map(|entry| entry.subkey_name.clone())
10263 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10264
10265 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10266
10267 let prev_dir_created = existing_entry
10272 .as_ref()
10273 .and_then(|entry| entry.install_directory_created)
10274 == Some(1);
10275 let dir_existed = target_dir.is_dir();
10276 if prev_dir_created && dir_existed {
10277 clean_directory_contents(&target_dir).with_context(|| {
10278 format!(
10279 "failed to clean existing portable install directory: {}",
10280 target_dir.display()
10281 )
10282 })?;
10283 }
10284
10285 fs::create_dir_all(&target_dir)
10286 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10287 let install_directory_created = !dir_existed || prev_dir_created;
10291
10292 let installer_type = installer.installer_type.as_deref().unwrap_or("");
10293 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10294 let file = fs::File::open(installer_path)
10295 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10296 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10297 archive
10298 .extract(&target_dir)
10299 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10300 installer
10304 .nested_installer_files
10305 .first()
10306 .map(|file| target_dir.join(&file.relative_file_path))
10307 .unwrap_or_else(|| target_dir.clone())
10308 } else {
10309 let basename = installer_path
10313 .file_name()
10314 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10315 let copied = target_dir.join(basename);
10316 fs::copy(installer_path, &copied)
10317 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10318 copied
10319 };
10320
10321 let alias = determine_portable_alias(installer);
10326 let symlink_full_path = match alias.as_deref() {
10327 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10328 _ => None,
10329 };
10330
10331 let added_to_path = if symlink_full_path.is_some() {
10335 try_add_links_dir_to_user_path()
10336 } else {
10337 false
10338 };
10339
10340 write_portable_arp_entry(&PortableArpEntry {
10341 subkey_name: &subkey_name,
10342 install_location: &target_dir,
10343 portable_target_full_path: &portable_target_full_path,
10344 portable_symlink_full_path: symlink_full_path.as_deref(),
10345 install_directory_created,
10346 added_to_path,
10347 source_identifier: &source_identifier,
10348 manifest,
10349 })?;
10350
10351 Ok(0)
10352}
10353
10354#[cfg(windows)]
10355struct PortableArpEntry<'a> {
10356 subkey_name: &'a str,
10357 install_location: &'a Path,
10358 portable_target_full_path: &'a Path,
10359 portable_symlink_full_path: Option<&'a Path>,
10360 install_directory_created: bool,
10361 added_to_path: bool,
10362 source_identifier: &'a str,
10363 manifest: &'a Manifest,
10364}
10365
10366#[cfg(windows)]
10372fn determine_portable_alias(installer: &Installer) -> Option<String> {
10373 installer
10374 .nested_installer_files
10375 .iter()
10376 .find_map(|file| file.portable_command_alias.clone())
10377 .or_else(|| installer.commands.first().cloned())
10378}
10379
10380#[cfg(windows)]
10381fn winget_links_dir() -> PathBuf {
10382 dirs::data_local_dir()
10383 .unwrap_or_else(|| PathBuf::from("."))
10384 .join("Microsoft")
10385 .join("WinGet")
10386 .join("Links")
10387}
10388
10389#[cfg(windows)]
10394fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10395 use std::os::windows::fs::symlink_file;
10396 let links_dir = winget_links_dir();
10397 if fs::create_dir_all(&links_dir).is_err() {
10398 return None;
10399 }
10400 let link_path = links_dir.join(format!("{alias}.exe"));
10401
10402 if link_path.symlink_metadata().is_ok() {
10405 let _ = fs::remove_file(&link_path);
10406 }
10407
10408 symlink_file(target, &link_path).ok().map(|()| link_path)
10413}
10414
10415#[cfg(windows)]
10421fn try_add_links_dir_to_user_path() -> bool {
10422 let links_dir = winget_links_dir();
10423 let links_str = links_dir.to_string_lossy().to_string();
10424 let normalized_links = links_str.to_ascii_lowercase();
10425
10426 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10427 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10428 return false;
10429 };
10430 let existing: String = env_key.get_value("Path").unwrap_or_default();
10431 let already_present = existing
10432 .split(';')
10433 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10434 if already_present {
10435 return false;
10436 }
10437
10438 let new_path = if existing.is_empty() {
10439 links_str
10440 } else if existing.ends_with(';') {
10441 format!("{existing}{links_str}")
10442 } else {
10443 format!("{existing};{links_str}")
10444 };
10445
10446 if env_key.set_value("Path", &new_path).is_err() {
10447 return false;
10448 }
10449
10450 broadcast_environment_change();
10451 true
10452}
10453
10454#[cfg(windows)]
10455fn broadcast_environment_change() {
10456 use std::ffi::OsStr;
10457 use std::os::windows::ffi::OsStrExt;
10458
10459 use windows_sys::Win32::UI::WindowsAndMessaging::{
10460 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10461 };
10462
10463 let param: Vec<u16> = OsStr::new("Environment")
10464 .encode_wide()
10465 .chain(std::iter::once(0))
10466 .collect();
10467 unsafe {
10471 let mut result: usize = 0;
10472 SendMessageTimeoutW(
10473 HWND_BROADCAST,
10474 WM_SETTINGCHANGE,
10475 0usize,
10476 param.as_ptr() as isize,
10477 SMTO_ABORTIFHUNG,
10478 5000,
10479 &mut result,
10480 );
10481 }
10482}
10483
10484#[cfg(windows)]
10489fn portable_source_identifier(query: &PackageQuery) -> String {
10490 match query.source.as_deref() {
10491 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10492 Some(name) if !name.is_empty() => name.to_owned(),
10493 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10494 }
10495}
10496
10497#[cfg(windows)]
10498fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10499 format!("{package_id}_{source_identifier}")
10500}
10501
10502#[cfg(windows)]
10503struct ExistingPortableEntry {
10504 install_location: Option<PathBuf>,
10505 install_directory_created: Option<u32>,
10506 subkey_name: String,
10507}
10508
10509#[cfg(windows)]
10510fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10511 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10512 let uninstall = hkcu
10513 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10514 .ok()?;
10515 for name in uninstall.enum_keys().flatten() {
10516 let Ok(subkey) = uninstall.open_subkey(&name) else {
10517 continue;
10518 };
10519 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10520 continue;
10521 }
10522 if !read_reg_string(&subkey, "WinGetInstallerType")
10523 .as_deref()
10524 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10525 {
10526 continue;
10527 }
10528 return Some(ExistingPortableEntry {
10529 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10530 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10531 subkey_name: name,
10532 });
10533 }
10534 None
10535}
10536
10537#[cfg(windows)]
10538fn resolve_portable_install_location(
10539 request: &InstallRequest,
10540 existing: &Option<ExistingPortableEntry>,
10541 subkey_name: &str,
10542) -> PathBuf {
10543 resolve_portable_install_location_pure(
10544 request.install_location.as_deref(),
10545 existing
10546 .as_ref()
10547 .and_then(|e| e.install_location.as_deref())
10548 .and_then(Path::to_str),
10549 subkey_name,
10550 &default_user_portable_root(),
10551 )
10552}
10553
10554#[cfg(windows)]
10559fn resolve_portable_install_location_pure(
10560 request_location: Option<&str>,
10561 existing_location: Option<&str>,
10562 subkey_name: &str,
10563 default_user_portable_root: &Path,
10564) -> PathBuf {
10565 if let Some(custom) = request_location
10566 && !custom.is_empty()
10567 {
10568 return PathBuf::from(custom);
10569 }
10570 if let Some(loc) = existing_location
10571 && !loc.is_empty()
10572 {
10573 return PathBuf::from(loc);
10574 }
10575 default_user_portable_root.join(subkey_name)
10576}
10577
10578#[cfg(windows)]
10579fn default_user_portable_root() -> PathBuf {
10580 dirs::data_local_dir()
10581 .unwrap_or_else(|| PathBuf::from("."))
10582 .join("Microsoft")
10583 .join("WinGet")
10584 .join("Packages")
10585}
10586
10587#[cfg(windows)]
10588fn clean_directory_contents(dir: &Path) -> Result<()> {
10589 if !dir.is_dir() {
10590 return Ok(());
10591 }
10592 for entry in fs::read_dir(dir)? {
10593 let entry = entry?;
10594 let path = entry.path();
10595 if path.is_dir() {
10596 fs::remove_dir_all(&path)?;
10597 } else {
10598 fs::remove_file(&path)?;
10599 }
10600 }
10601 Ok(())
10602}
10603
10604#[cfg(windows)]
10608fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10609 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10610 let uninstall_path = format!(
10611 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10612 entry.subkey_name
10613 );
10614 let (subkey, _) = hkcu
10615 .create_subkey(&uninstall_path)
10616 .context("failed to create portable ARP registry subkey")?;
10617
10618 let manifest = entry.manifest;
10619 let install_location_str = entry.install_location.to_string_lossy().to_string();
10620 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10621 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10622 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10623 subkey.set_value("InstallLocation", &install_location_str)?;
10624 subkey.set_value(
10625 "PortableTargetFullPath",
10626 &entry.portable_target_full_path.to_string_lossy().to_string(),
10627 )?;
10628 if let Some(symlink_path) = entry.portable_symlink_full_path {
10629 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10630 }
10631 subkey.set_value(
10632 "InstallDirectoryAddedToPath",
10633 &(if entry.added_to_path { 1u32 } else { 0u32 }),
10634 )?;
10635 subkey.set_value(
10636 "InstallDirectoryCreated",
10637 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10638 )?;
10639 subkey.set_value(
10640 "DisplayName",
10641 &if manifest.name.is_empty() {
10642 manifest.id.clone()
10643 } else {
10644 manifest.name.clone()
10645 },
10646 )?;
10647 subkey.set_value("DisplayVersion", &manifest.version)?;
10648 if let Some(publisher) = manifest.publisher.as_ref() {
10649 subkey.set_value("Publisher", publisher)?;
10650 }
10651 subkey.set_value(
10652 "UninstallString",
10653 &format!("winget uninstall --product-code {}", entry.subkey_name),
10654 )?;
10655 let today = Utc::now().format("%Y%m%d").to_string();
10656 subkey.set_value("InstallDate", &today)?;
10657 if let Some(url) = manifest.package_url.as_ref() {
10658 subkey.set_value("URLInfoAbout", url)?;
10659 }
10660
10661 Ok(())
10662}
10663
10664#[cfg(test)]
10665mod tests {
10666 use std::io::Write;
10667 use std::net::{SocketAddr, TcpListener, TcpStream};
10668 use std::path::{Path, PathBuf};
10669 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10670 use std::thread::{self, JoinHandle};
10671
10672 use flate2::Compression;
10673 use flate2::write::DeflateEncoder;
10674 use zip::write::SimpleFileOptions;
10675
10676 use super::*;
10677
10678 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10679 Manifest {
10680 id: id.to_owned(),
10681 name: name.to_owned(),
10682 version: version.to_owned(),
10683 channel: String::new(),
10684 publisher: Some("Acme".to_owned()),
10685 description: Some("desc".to_owned()),
10686 moniker: None,
10687 package_url: None,
10688 publisher_url: None,
10689 publisher_support_url: None,
10690 license: None,
10691 license_url: None,
10692 privacy_url: None,
10693 author: None,
10694 copyright: None,
10695 copyright_url: None,
10696 release_notes: None,
10697 release_notes_url: None,
10698 tags: Vec::new(),
10699 agreements: Vec::new(),
10700 package_dependencies: Vec::new(),
10701 documentation: Vec::new(),
10702 icons: Vec::new(),
10703 installers: Vec::new(),
10704 require_explicit_upgrade: false,
10705 }
10706 }
10707
10708 #[test]
10709 fn derive_download_filename_matches_winget_pattern() {
10710 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10711 let installer = Installer {
10712 architecture: Some("arm64".to_owned()),
10713 installer_type: Some("exe".to_owned()),
10714 scope: Some("user".to_owned()),
10715 locale: Some("en-US".to_owned()),
10716 ..Installer::default()
10717 };
10718 assert_eq!(
10719 derive_download_manifest_filename(&manifest, &installer),
10720 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10721 );
10722 }
10723
10724 #[test]
10725 fn derive_download_filename_omits_optional_components() {
10726 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10727 let installer = Installer {
10728 architecture: Some("x64".to_owned()),
10729 installer_type: Some("exe".to_owned()),
10730 ..Installer::default()
10731 };
10732 assert_eq!(
10734 derive_download_manifest_filename(&manifest, &installer),
10735 "7-Zip_26.01_X64_exe.yaml"
10736 );
10737 }
10738
10739 #[test]
10740 fn derive_download_filename_skips_unknown_version() {
10741 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10742 let installer = Installer {
10743 architecture: Some("neutral".to_owned()),
10744 installer_type: Some("msi".to_owned()),
10745 ..Installer::default()
10746 };
10747 assert_eq!(
10748 derive_download_manifest_filename(&manifest, &installer),
10749 "Test_Neutral_msi.yaml"
10750 );
10751 }
10752
10753 #[test]
10754 fn derive_download_filename_sanitizes_illegal_chars() {
10755 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10756 let installer = Installer {
10757 architecture: Some("x64".to_owned()),
10758 installer_type: Some("exe".to_owned()),
10759 ..Installer::default()
10760 };
10761 let filename = derive_download_manifest_filename(&manifest, &installer);
10763 assert!(!filename.contains('/') && !filename.contains(':'));
10764 assert!(filename.starts_with("Some_Bad_Name_1.0"));
10765 }
10766
10767 #[test]
10768 fn build_download_manifest_yaml_overrides_identity_fields() {
10769 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10770 let documents = serde_json::json!({
10773 "PackageIdentifier": "stale-id",
10774 "PackageVersion": "",
10775 "PackageName": "Old Name",
10776 "Channel": "preview",
10777 "Installers": [
10778 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10779 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10780 ],
10781 });
10782 let installer = Installer {
10783 architecture: Some("x64".to_owned()),
10784 installer_type: Some("exe".to_owned()),
10785 url: Some("https://example.com/a".to_owned()),
10786 ..Installer::default()
10787 };
10788 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10789 assert!(yaml.contains("PackageIdentifier: App.Id"));
10790 assert!(yaml.contains("PackageVersion: 2.0.0"));
10791 assert!(yaml.contains("PackageName: App Name"));
10792 assert!(yaml.contains("ManifestType: merged"));
10793 assert!(!yaml.contains("Channel: preview"));
10796 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
10798 assert!(yaml.contains("https://example.com/a"));
10799 assert!(!yaml.contains("https://example.com/b"));
10800 }
10801
10802 #[test]
10803 fn build_download_manifest_yaml_preserves_typed_channel() {
10804 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10805 manifest.channel = "beta".to_owned();
10806 let documents = serde_json::json!({
10807 "PackageIdentifier": "App.Id",
10808 "PackageVersion": "2.0.0",
10809 "Installers": [],
10810 });
10811 let installer = Installer::default();
10812 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10813 assert!(yaml.contains("Channel: beta"));
10814 }
10815
10816 fn temp_app_root(label: &str) -> PathBuf {
10817 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
10818 std::env::temp_dir().join(format!(
10819 "pinget-rs-tests-{label}-{}-{}",
10820 std::process::id(),
10821 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
10822 ))
10823 }
10824
10825 #[cfg(windows)]
10826 #[test]
10827 fn path_prefix_check_rejects_string_prefix_collisions() {
10828 assert!(!path_starts_with_case_insensitive(
10832 Path::new(r"C:\Foobar\bin.exe"),
10833 Path::new(r"C:\Foo"),
10834 ));
10835 assert!(!path_starts_with_case_insensitive(
10836 Path::new(r"C:\Foo\bin.exe.bak"),
10837 Path::new(r"C:\Foo\bin.exe"),
10838 ));
10839 }
10840
10841 #[cfg(windows)]
10842 #[test]
10843 fn path_prefix_check_accepts_real_children_and_self() {
10844 assert!(path_starts_with_case_insensitive(
10846 Path::new(r"C:\Foo\sub\bin.exe"),
10847 Path::new(r"C:\Foo"),
10848 ));
10849 assert!(path_starts_with_case_insensitive(
10851 Path::new(r"C:\Foo\bin.exe"),
10852 Path::new(r"C:\Foo\bin.exe"),
10853 ));
10854 }
10855
10856 #[cfg(windows)]
10857 #[test]
10858 fn path_prefix_check_is_case_insensitive() {
10859 assert!(path_starts_with_case_insensitive(
10862 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
10863 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
10864 ));
10865 }
10866
10867 #[cfg(windows)]
10868 #[test]
10869 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
10870 let query = PackageQuery {
10871 source: Some("winget".to_owned()),
10872 ..PackageQuery::default()
10873 };
10874 assert_eq!(
10875 portable_source_identifier(&query),
10876 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10877 );
10878 let query = PackageQuery {
10880 source: Some("WINGET".to_owned()),
10881 ..PackageQuery::default()
10882 };
10883 assert_eq!(
10884 portable_source_identifier(&query),
10885 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10886 );
10887 }
10888
10889 #[cfg(windows)]
10890 #[test]
10891 fn portable_source_identifier_passes_through_custom_sources() {
10892 let query = PackageQuery {
10893 source: Some("internal-feed".to_owned()),
10894 ..PackageQuery::default()
10895 };
10896 assert_eq!(portable_source_identifier(&query), "internal-feed");
10897 }
10898
10899 #[cfg(windows)]
10900 #[test]
10901 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
10902 let query = PackageQuery {
10903 source: None,
10904 ..PackageQuery::default()
10905 };
10906 assert_eq!(
10907 portable_source_identifier(&query),
10908 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10909 );
10910 let query = PackageQuery {
10911 source: Some(String::new()),
10912 ..PackageQuery::default()
10913 };
10914 assert_eq!(
10915 portable_source_identifier(&query),
10916 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10917 );
10918 }
10919
10920 #[cfg(windows)]
10921 #[test]
10922 fn portable_subkey_name_matches_winget_format() {
10923 assert_eq!(
10924 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
10925 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
10926 );
10927 }
10928
10929 #[cfg(windows)]
10930 #[test]
10931 fn determine_portable_alias_prefers_nested_installer_alias() {
10932 let installer = Installer {
10933 nested_installer_files: vec![NestedInstallerFile {
10934 relative_file_path: "ripgrep/rg.exe".to_owned(),
10935 portable_command_alias: Some("rg".to_owned()),
10936 }],
10937 commands: vec!["other-name".to_owned()],
10938 ..Installer::default()
10939 };
10940 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
10941 }
10942
10943 #[cfg(windows)]
10944 #[test]
10945 fn determine_portable_alias_falls_back_to_commands() {
10946 let installer = Installer {
10947 commands: vec!["nuget".to_owned()],
10949 ..Installer::default()
10950 };
10951 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
10952
10953 let installer = Installer {
10955 nested_installer_files: vec![NestedInstallerFile {
10956 relative_file_path: "subdir/bin.exe".to_owned(),
10957 portable_command_alias: None,
10958 }],
10959 commands: vec!["bin".to_owned()],
10960 ..Installer::default()
10961 };
10962 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
10963 }
10964
10965 #[cfg(windows)]
10966 #[test]
10967 fn determine_portable_alias_returns_none_without_alias_or_commands() {
10968 let installer = Installer::default();
10969 assert!(determine_portable_alias(&installer).is_none());
10970 }
10971
10972 #[cfg(windows)]
10973 #[test]
10974 fn resolve_portable_install_location_prefers_request_override() {
10975 let result = resolve_portable_install_location_pure(
10976 Some(r"D:\Tools\rg"),
10977 Some(r"C:\Software\rg-test"),
10978 "Sub_Source",
10979 Path::new(r"C:\default"),
10980 );
10981 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
10982 }
10983
10984 #[cfg(windows)]
10985 #[test]
10986 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
10987 let result = resolve_portable_install_location_pure(
10991 None,
10992 Some(r"C:\Software\rg-test"),
10993 "Sub_Source",
10994 Path::new(r"C:\default"),
10995 );
10996 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
10997 }
10998
10999 #[cfg(windows)]
11000 #[test]
11001 fn resolve_portable_install_location_falls_back_to_default_root() {
11002 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
11003 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11004 }
11005
11006 #[cfg(windows)]
11007 #[test]
11008 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
11009 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
11012 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11013 }
11014
11015 #[cfg(windows)]
11016 #[test]
11017 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
11018 let root = std::env::temp_dir().join(format!(
11019 "pinget-clean-test-{}",
11020 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11021 ));
11022 fs::create_dir_all(&root).expect("create temp dir");
11023 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11024 fs::create_dir_all(root.join("nested")).expect("create nested dir");
11025 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11026
11027 clean_directory_contents(&root).expect("clean root contents");
11028
11029 assert!(root.is_dir(), "root dir itself must survive");
11030 assert_eq!(
11031 fs::read_dir(&root).expect("read root").count(),
11032 0,
11033 "root must be empty after clean"
11034 );
11035 let _ = fs::remove_dir_all(&root);
11036 }
11037
11038 #[cfg(windows)]
11039 #[test]
11040 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11041 let bogus = std::env::temp_dir().join(format!(
11042 "pinget-clean-test-bogus-{}",
11043 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11044 ));
11045 assert!(!bogus.exists());
11046 clean_directory_contents(&bogus).expect("clean nonexistent dir");
11047 assert!(!bogus.exists());
11049 }
11050
11051 #[cfg(windows)]
11052 #[test]
11053 fn path_prefix_check_handles_mixed_separators() {
11054 assert!(path_starts_with_case_insensitive(
11057 Path::new(r"C:\Foo\sub/bin.exe"),
11058 Path::new(r"C:\Foo"),
11059 ));
11060 }
11061
11062 #[test]
11063 fn repository_options_capture_custom_host_settings() {
11064 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11065 .with_user_agent("pinget-rs-tests/1.0")
11066 .with_pre_indexed_source_auto_update_interval(None);
11067
11068 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11069 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11070 assert!(options.pre_indexed_source_auto_update_interval.is_none());
11071 }
11072
11073 #[test]
11074 fn dependency_install_requests_are_silent_and_not_forced() {
11075 let mut parent = InstallRequest::new(PackageQuery {
11076 source: Some("winget".to_owned()),
11077 ..PackageQuery::default()
11078 });
11079 parent.mode = InstallerMode::SilentWithProgress;
11080 parent.force = true;
11081 parent.accept_package_agreements = true;
11082 parent.ignore_security_hash = true;
11083 parent.dependency_source = Some("dependencies".to_owned());
11084
11085 let dependency =
11086 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11087
11088 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11089 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11090 assert_eq!(dependency.mode, InstallerMode::Silent);
11091 assert!(!dependency.force);
11092 assert!(dependency.no_upgrade);
11093 assert!(dependency.accept_package_agreements);
11094 assert!(dependency.ignore_security_hash);
11095 }
11096
11097 #[test]
11098 fn storage_paths_use_configured_app_root() {
11099 let app_root = PathBuf::from(r"C:\temp\pinget-test");
11100 let source = SourceRecord {
11101 name: "winget/test".to_owned(),
11102 kind: SourceKind::PreIndexed,
11103 arg: "https://example.com/cache".to_owned(),
11104 identifier: "Test.Source".to_owned(),
11105 trust_level: "None".to_owned(),
11106 explicit: false,
11107 priority: 0,
11108 last_update: None,
11109 source_version: None,
11110 etag: None,
11111 last_modified: None,
11112 };
11113
11114 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11115 assert_eq!(
11116 source_state_dir(&app_root, &source),
11117 app_root.join("sources").join("winget_test")
11118 );
11119 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11120 }
11121
11122 #[cfg(windows)]
11123 #[test]
11124 fn packaged_layout_paths_resolve_from_packaged_app_root() {
11125 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11126 let app_root = local_app_data
11127 .join("Packages")
11128 .join(PACKAGED_FAMILY_NAME)
11129 .join("LocalState");
11130 let source = SourceStore::default()
11131 .sources
11132 .into_iter()
11133 .find(|source| source.name == "winget")
11134 .expect("winget source");
11135 assert!(uses_packaged_layout(&app_root));
11136 assert!(
11137 user_settings_path(&app_root)
11138 .display()
11139 .to_string()
11140 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11141 );
11142 assert!(
11143 packaged_file_cache_root(&app_root)
11144 .display()
11145 .to_string()
11146 .ends_with(&format!(
11147 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11148 PACKAGED_FAMILY_NAME
11149 ))
11150 );
11151 assert!(
11152 source_state_dir(&app_root, &source)
11153 .display()
11154 .to_string()
11155 .ends_with(&format!(
11156 r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11157 PACKAGED_FAMILY_NAME
11158 ))
11159 );
11160 assert!(
11161 source_installed_db_path_with_keying(&app_root, &source, false)
11162 .display()
11163 .to_string()
11164 .ends_with(&format!(
11165 r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11166 PACKAGED_FAMILY_NAME
11167 ))
11168 );
11169 }
11170
11171 #[test]
11172 fn source_mode_parses_accepted_spellings() {
11173 assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto));
11174 assert_eq!(SourceMode::parse(" Auto "), Some(SourceMode::Auto));
11175 assert_eq!(SourceMode::parse("PRIVATE"), Some(SourceMode::Private));
11176 assert_eq!(
11177 SourceMode::parse("system-winget-mirror"),
11178 Some(SourceMode::SystemWingetMirror)
11179 );
11180 assert_eq!(
11181 SourceMode::parse("SystemWingetMirror"),
11182 Some(SourceMode::SystemWingetMirror)
11183 );
11184 }
11185
11186 #[test]
11187 fn source_mode_rejects_unknown_spellings() {
11188 assert_eq!(SourceMode::parse(""), None);
11189 assert_eq!(SourceMode::parse("mirror"), None);
11190 assert_eq!(SourceMode::parse("system_winget_mirror"), None);
11191 }
11192
11193 #[test]
11194 fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() {
11195 assert_eq!(SourceMode::resolve(None, true), SourceMode::Private);
11196 assert_eq!(SourceMode::resolve(None, false), SourceMode::Auto);
11197 }
11198
11199 #[test]
11200 fn source_mode_resolution_lets_the_environment_override_a_custom_app_root() {
11201 assert_eq!(SourceMode::resolve(Some(SourceMode::Auto), true), SourceMode::Auto);
11202 assert_eq!(
11203 SourceMode::resolve(Some(SourceMode::SystemWingetMirror), true),
11204 SourceMode::SystemWingetMirror
11205 );
11206 assert_eq!(
11207 SourceMode::resolve(Some(SourceMode::Private), false),
11208 SourceMode::Private
11209 );
11210 }
11211
11212 #[cfg(windows)]
11213 #[test]
11214 fn default_app_root_outside_package_avoids_packaged_layout() {
11215 let prior = std::env::var_os(APP_ROOT_ENV_VAR);
11219 unsafe { std::env::remove_var(APP_ROOT_ENV_VAR) };
11221
11222 let result = default_app_root();
11223
11224 if let Some(prior) = prior {
11225 unsafe { std::env::set_var(APP_ROOT_ENV_VAR, prior) };
11227 }
11228
11229 let app_root = result.expect("default app root");
11230 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11231 assert!(
11232 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11233 "unexpected fallback app root: {app_root:?}"
11234 );
11235 }
11236
11237 #[test]
11238 fn packaged_source_stream_overlays_defaults_and_metadata() {
11239 let store = parse_packaged_source_store(
11240 Some(
11241 "Sources:\n - Name: winget\n Type: Microsoft.PreIndexed.Package\n Arg: https://cdn.winget.microsoft.com/cache\n Data: Microsoft.Winget.Source_8wekyb3d8bbwe\n IsTombstone: true\n - Name: corp\n Type: Microsoft.Rest\n Arg: https://packages.contoso.test/api\n Data: Contoso.Rest\n Explicit: true\n Priority: 7\n TrustLevel: 1\n IsTombstone: false\n",
11242 ),
11243 Some(
11244 "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n ETag: \"etag-v1\"\n LastModified: \"Wed, 03 Jun 2026 12:00:00 GMT\"\n",
11245 ),
11246 )
11247 .expect("packaged store");
11248
11249 assert!(!store.sources.iter().any(|source| source.name == "winget"));
11250 let corp = store
11251 .sources
11252 .iter()
11253 .find(|source| source.name == "corp")
11254 .expect("corp source");
11255 assert_eq!(corp.kind, SourceKind::Rest);
11256 assert_eq!(corp.identifier, "Contoso.Rest");
11257 assert_eq!(corp.trust_level, "Trusted");
11258 assert!(corp.explicit);
11259 assert_eq!(corp.priority, 7);
11260 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11261 assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11262 assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11263 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11264 }
11265
11266 #[test]
11267 fn system_winget_export_parses_json_lines() {
11268 let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11269{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11270{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
11271{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
11272"#;
11273
11274 let sources = parse_system_winget_source_export(output).expect("source export");
11275
11276 assert_eq!(
11277 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11278 vec!["winget.pro", "msstore", "winget", "winget-font"]
11279 );
11280 assert_eq!(sources[0].kind, SourceKind::Rest);
11281 assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11282 assert_eq!(sources[0].identifier, "api.winget.pro");
11283 assert_eq!(sources[0].trust_level, "Trusted");
11284 assert!(!sources[0].explicit);
11285 assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11286 assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11287 assert!(!sources[2].explicit);
11288 }
11289
11290 #[test]
11291 fn system_winget_export_parses_wrapped_sources() {
11292 let output = r#"{
11293 "Sources": [
11294 {
11295 "Name": "corp",
11296 "Type": "Microsoft.Rest",
11297 "Arg": "https://packages.contoso.test/api",
11298 "Identifier": "Contoso.Rest",
11299 "TrustLevel": "Trusted",
11300 "Explicit": true,
11301 "Priority": 9
11302 }
11303 ]
11304}"#;
11305
11306 let sources = parse_system_winget_source_export(output).expect("source export");
11307 let source = sources.first().expect("source");
11308 assert_eq!(sources.len(), 1);
11309 assert_eq!(source.name, "corp");
11310 assert_eq!(source.kind, SourceKind::Rest);
11311 assert_eq!(source.identifier, "Contoso.Rest");
11312 assert_eq!(source.trust_level, "Trusted");
11313 assert!(source.explicit);
11314 assert_eq!(source.priority, 9);
11315 }
11316
11317 #[test]
11318 fn system_winget_source_store_detects_secure_settings_stub() {
11319 assert!(is_secure_settings_stub(Some(
11320 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11321 )));
11322 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
11323 }
11324
11325 #[test]
11326 fn system_winget_source_store_builds_add_arguments() {
11327 let args = build_winget_source_add_arguments(
11328 "corp",
11329 "https://packages.contoso.test/api",
11330 SourceKind::Rest,
11331 "Trusted",
11332 true,
11333 );
11334
11335 assert_eq!(
11336 args,
11337 vec![
11338 "source",
11339 "add",
11340 "--name",
11341 "corp",
11342 "--arg",
11343 "https://packages.contoso.test/api",
11344 "--type",
11345 "Microsoft.Rest",
11346 "--disable-interactivity",
11347 "--trust-level",
11348 "trusted",
11349 "--explicit",
11350 ]
11351 );
11352 }
11353
11354 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11355 Ok(WingetSourceCommandResult {
11356 exit_code: 0,
11357 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11358"#
11359 .to_owned(),
11360 stderr: String::new(),
11361 })
11362 }
11363
11364 #[test]
11365 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11366 let original_runner = {
11367 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11368 let original = *runner;
11369 *runner = fake_system_winget_source_export;
11370 original
11371 };
11372
11373 let result = load_packaged_store_from_streams(
11374 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11375 None,
11376 );
11377
11378 {
11379 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11380 *runner = original_runner;
11381 }
11382
11383 let store = result.expect("packaged store").expect("packaged store");
11384 let source = store.sources.first().expect("source");
11385 assert_eq!(store.sources.len(), 1);
11386 assert_eq!(source.name, "contoso");
11387 assert_eq!(source.kind, SourceKind::Rest);
11388 }
11389
11390 #[test]
11391 fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11392 let original_runner = {
11393 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11394 let original = *runner;
11395 *runner = fake_system_winget_source_export;
11396 original
11397 };
11398
11399 let app_root = temp_app_root("system-winget-mirror");
11400 let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11401
11402 {
11403 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11404 *runner = original_runner;
11405 }
11406
11407 let (mode, mut store) = result.expect("mirror store");
11408 assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11409 assert!(system_winget_mirror_store_path(&app_root).exists());
11410 assert!(
11411 !store_path(&app_root).exists(),
11412 "mirror mode must not write private sources.json"
11413 );
11414
11415 let source = store.sources.first_mut().expect("source");
11416 source.last_update = Some(Utc::now());
11417 source.source_version = Some("cached-contract".to_owned());
11418 source.etag = Some("\"etag\"".to_owned());
11419 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11420 save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11421
11422 let original_runner = {
11423 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11424 let original = *runner;
11425 *runner = fake_system_winget_source_export;
11426 original
11427 };
11428 let refreshed = refresh_system_winget_mirror_store(&app_root);
11429 {
11430 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11431 *runner = original_runner;
11432 }
11433
11434 let refreshed = refreshed.expect("refresh mirror");
11435 let source = refreshed.sources.first().expect("source");
11436 assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11437 assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11438 assert_eq!(
11439 source_state_dir_with_keying(&app_root, source, true),
11440 app_root.join("sources").join("api.contoso.test")
11441 );
11442 assert_eq!(
11443 source_state_dir_with_keying(&app_root, source, false),
11444 app_root.join("sources").join("contoso")
11445 );
11446
11447 let _ = fs::remove_dir_all(&app_root);
11448 }
11449
11450 #[test]
11451 fn source_metadata_and_named_reset_round_trip() {
11452 let app_root = temp_app_root("source-reset");
11453 let result = (|| -> Result<()> {
11454 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11455 repository.add_source_with_metadata(
11456 "test",
11457 "https://example.com/test",
11458 SourceKind::Rest,
11459 Some("trusted"),
11460 true,
11461 4,
11462 )?;
11463
11464 repository.edit_source("test", Some(false), None)?;
11465 let edited = repository
11466 .list_sources()
11467 .into_iter()
11468 .find(|source| source.name == "test")
11469 .expect("source");
11470 assert_eq!(edited.trust_level, "Trusted");
11471 assert!(!edited.explicit);
11472 assert_eq!(edited.priority, 4);
11473
11474 repository.reset_source("test")?;
11475 let reset = repository
11476 .list_sources()
11477 .into_iter()
11478 .find(|source| source.name == "test")
11479 .expect("source");
11480 assert_eq!(reset.trust_level, "Trusted");
11481 assert!(!reset.explicit);
11482 assert_eq!(reset.priority, 4);
11483 assert!(reset.last_update.is_none());
11484 assert!(reset.source_version.is_none());
11485 Ok(())
11486 })();
11487
11488 let _ = fs::remove_dir_all(&app_root);
11489 result.expect("source metadata");
11490 }
11491
11492 #[test]
11493 fn explicit_sources_are_skipped_by_default_search_resolution() {
11494 let app_root = temp_app_root("source-resolution");
11495 let result = (|| -> Result<()> {
11496 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11497 repository.add_source_with_metadata(
11498 "explicit-test",
11499 "https://example.com/explicit",
11500 SourceKind::Rest,
11501 Some("trusted"),
11502 true,
11503 10,
11504 )?;
11505
11506 let indexes = repository.resolve_source_indexes(None)?;
11507 let names: Vec<_> = indexes
11508 .into_iter()
11509 .map(|index| repository.list_sources()[index].name.clone())
11510 .collect();
11511 assert!(!names.iter().any(|name| name == "explicit-test"));
11512 Ok(())
11513 })();
11514
11515 let _ = fs::remove_dir_all(&app_root);
11516 result.expect("source resolution");
11517 }
11518
11519 #[test]
11520 fn source_resolution_accepts_source_identifier() {
11521 let app_root = temp_app_root("source-identifier-resolution");
11522 let result = (|| -> Result<()> {
11523 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11524 repository.add_source_with_metadata(
11525 "winget.pro",
11526 "https://api.winget.pro/feed",
11527 SourceKind::Rest,
11528 Some("trusted"),
11529 false,
11530 0,
11531 )?;
11532 repository.store.sources[0].identifier = "api.winget.pro".to_owned();
11533
11534 let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
11535 assert_eq!(indexes, vec![0]);
11536 Ok(())
11537 })();
11538
11539 let _ = fs::remove_dir_all(&app_root);
11540 result.expect("source identifier resolution");
11541 }
11542
11543 #[test]
11544 fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
11545 let query = PackageQuery {
11546 id: Some("tessl.tessl".to_owned()),
11547 source: Some("winget".to_owned()),
11548 exact: true,
11549 ..PackageQuery::default()
11550 };
11551 let clean_miss = SearchLocatedResult {
11552 matches: Vec::new(),
11553 warnings: Vec::new(),
11554 failures: Vec::new(),
11555 truncated: false,
11556 };
11557 assert!(should_retry_show_across_system_winget_sources(
11558 true,
11559 &query,
11560 &clean_miss
11561 ));
11562
11563 let private_mode = false;
11564 assert!(!should_retry_show_across_system_winget_sources(
11565 private_mode,
11566 &query,
11567 &clean_miss
11568 ));
11569
11570 let explicit_custom_source = PackageQuery {
11571 source: Some("winget.pro".to_owned()),
11572 ..query.clone()
11573 };
11574 assert!(!should_retry_show_across_system_winget_sources(
11575 true,
11576 &explicit_custom_source,
11577 &clean_miss
11578 ));
11579
11580 let failed_source = SearchLocatedResult {
11581 failures: vec![SourceSearchFailure {
11582 source_name: "winget".to_owned(),
11583 message: "network failure".to_owned(),
11584 }],
11585 ..clean_miss
11586 };
11587 assert!(!should_retry_show_across_system_winget_sources(
11588 true,
11589 &query,
11590 &failed_source
11591 ));
11592 }
11593
11594 #[test]
11595 fn user_and_admin_settings_round_trip() {
11596 let app_root = temp_app_root("settings");
11597 let result = (|| -> Result<()> {
11598 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11599 repository.set_user_settings(
11600 &serde_json::json!({
11601 "visual": {
11602 "progressBar": "retro"
11603 }
11604 }),
11605 false,
11606 )?;
11607 repository.set_user_settings(
11608 &serde_json::json!({
11609 "experimentalFeatures": {
11610 "directMSI": true
11611 }
11612 }),
11613 true,
11614 )?;
11615
11616 let user_settings = repository.get_user_settings()?;
11617 assert_eq!(
11618 user_settings["visual"]["progressBar"],
11619 serde_json::Value::String("retro".to_owned())
11620 );
11621 assert_eq!(
11622 user_settings["experimentalFeatures"]["directMSI"],
11623 serde_json::Value::Bool(true)
11624 );
11625 assert!(repository.test_user_settings(
11626 &serde_json::json!({
11627 "experimentalFeatures": {
11628 "directMSI": true
11629 }
11630 }),
11631 true
11632 )?);
11633
11634 repository.set_admin_setting("LocalManifestFiles", true)?;
11635 repository.set_admin_setting("InstallerHashOverride", true)?;
11636 let admin_settings = repository.get_admin_settings()?;
11637 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
11638 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
11639
11640 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
11641 let reset_one = repository.get_admin_settings()?;
11642 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
11643
11644 repository.reset_admin_setting(None, true)?;
11645 let reset_all = repository.get_admin_settings()?;
11646 for name in Repository::supported_admin_settings() {
11647 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
11648 }
11649 Ok(())
11650 })();
11651
11652 let _ = fs::remove_dir_all(&app_root);
11653 result.expect("settings");
11654 }
11655
11656 #[test]
11657 fn show_result_structured_document_is_manifest_oriented() {
11658 let result = ShowResult {
11659 package: SearchMatch {
11660 source_name: "winget".to_owned(),
11661 source_kind: SourceKind::PreIndexed,
11662 id: "Test.Package".to_owned(),
11663 name: "Test Package".to_owned(),
11664 moniker: Some("testpkg".to_owned()),
11665 version: Some("1.2.3".to_owned()),
11666 channel: Some("stable".to_owned()),
11667 match_criteria: Some("Id".to_owned()),
11668 },
11669 manifest: Manifest {
11670 id: "Test.Package".to_owned(),
11671 name: "Test Package".to_owned(),
11672 version: "1.2.3".to_owned(),
11673 channel: "stable".to_owned(),
11674 publisher: Some("Contoso".to_owned()),
11675 description: Some("Structured output".to_owned()),
11676 moniker: Some("testpkg".to_owned()),
11677 package_url: None,
11678 publisher_url: None,
11679 publisher_support_url: None,
11680 license: None,
11681 license_url: None,
11682 privacy_url: None,
11683 author: None,
11684 copyright: None,
11685 copyright_url: None,
11686 release_notes: None,
11687 release_notes_url: None,
11688 tags: vec!["utility".to_owned()],
11689 agreements: Vec::new(),
11690 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
11691 documentation: vec![Documentation {
11692 label: Some("Docs".to_owned()),
11693 url: "https://example.test/docs".to_owned(),
11694 }],
11695 icons: vec![PackageIcon {
11696 icon_url: Some("https://example.test/default-256.ico".to_owned()),
11697 icon_file_type: Some("ico".to_owned()),
11698 icon_resolution: Some("256x256".to_owned()),
11699 icon_theme: Some("default".to_owned()),
11700 icon_sha256: Some("DEF456".to_owned()),
11701 }],
11702 installers: vec![Installer {
11703 architecture: Some("x64".to_owned()),
11704 installer_type: Some("msix".to_owned()),
11705 url: Some("https://example.test/Test.Package.msix".to_owned()),
11706 sha256: Some("ABC123".to_owned()),
11707 product_code: None,
11708 locale: Some("en-US".to_owned()),
11709 scope: Some("machine".to_owned()),
11710 release_date: None,
11711 package_family_name: None,
11712 upgrade_code: None,
11713 platforms: Vec::new(),
11714 minimum_os_version: None,
11715 switches: InstallerSwitches {
11716 silent: Some("/quiet".to_owned()),
11717 ..InstallerSwitches::default()
11718 },
11719 commands: vec!["testpkg".to_owned()],
11720 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11721 require_explicit_upgrade: false,
11722 nested_installer_type: None,
11723 nested_installer_files: Vec::new(),
11724 }],
11725 require_explicit_upgrade: false,
11726 },
11727 selected_installer: Some(Installer {
11728 architecture: Some("x64".to_owned()),
11729 installer_type: Some("msix".to_owned()),
11730 url: Some("https://example.test/Test.Package.msix".to_owned()),
11731 sha256: Some("ABC123".to_owned()),
11732 product_code: None,
11733 locale: Some("en-US".to_owned()),
11734 scope: Some("machine".to_owned()),
11735 release_date: None,
11736 package_family_name: None,
11737 upgrade_code: None,
11738 platforms: Vec::new(),
11739 minimum_os_version: None,
11740 switches: InstallerSwitches {
11741 silent: Some("/quiet".to_owned()),
11742 ..InstallerSwitches::default()
11743 },
11744 commands: vec!["testpkg".to_owned()],
11745 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11746 require_explicit_upgrade: false,
11747 nested_installer_type: None,
11748 nested_installer_files: Vec::new(),
11749 }),
11750 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
11751 warnings: vec!["cache warmed".to_owned()],
11752 manifest_documents: JsonValue::Array(vec![
11753 serde_json::json!({
11754 "PackageIdentifier": "Test.Package",
11755 "PackageVersion": "1.2.3",
11756 "DefaultLocale": "en-US",
11757 "ManifestType": "version",
11758 "ManifestVersion": "1.10.0"
11759 }),
11760 serde_json::json!({
11761 "PackageIdentifier": "Test.Package",
11762 "PackageVersion": "1.2.3",
11763 "PackageLocale": "en-US",
11764 "PackageName": "Test Package",
11765 "Publisher": "Example",
11766 "License": "MIT",
11767 "ShortDescription": "Structured output",
11768 "Icons": [
11769 {
11770 "IconUrl": "https://example.test/default-256.ico",
11771 "IconFileType": "ico",
11772 "IconResolution": "256x256",
11773 "IconTheme": "default",
11774 "IconSha256": "DEF456"
11775 }
11776 ],
11777 "ManifestType": "defaultLocale",
11778 "ManifestVersion": "1.10.0"
11779 }),
11780 serde_json::json!({
11781 "PackageIdentifier": "Test.Package",
11782 "PackageVersion": "1.2.3",
11783 "ManifestType": "installer",
11784 "ManifestVersion": "1.10.0",
11785 "Installers": [
11786 {
11787 "Architecture": "x64",
11788 "InstallerType": "msix",
11789 "InstallerUrl": "https://example.test/Test.Package.msix",
11790 "InstallerSha256": "ABC123",
11791 "Commands": ["testpkg"],
11792 "InstallerSwitches": { "Silent": "/quiet" },
11793 "Dependencies": {
11794 "PackageDependencies": [
11795 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
11796 ]
11797 }
11798 }
11799 ]
11800 }),
11801 ]),
11802 };
11803
11804 let document = result.structured_document();
11805
11806 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
11807 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
11808 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
11809 assert_eq!(
11810 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
11811 Some("Microsoft.VCRedist.2015+.x64")
11812 );
11813 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
11814 assert_eq!(
11815 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
11816 Some("/quiet")
11817 );
11818 assert_eq!(
11819 document["IconUrl"].as_str(),
11820 Some("https://example.test/default-256.ico")
11821 );
11822 assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
11823 assert_eq!(
11824 document["Icons"][0]["IconUrl"].as_str(),
11825 Some("https://example.test/default-256.ico")
11826 );
11827 }
11828
11829 #[test]
11830 fn manifest_parses_require_explicit_upgrade_at_top_level() {
11831 let yaml = r#"
11836PackageIdentifier: Test.Package
11837PackageVersion: 1.2.3
11838DefaultLocale: en-US
11839ManifestType: singleton
11840ManifestVersion: 1.10.0
11841PackageLocale: en-US
11842PackageName: Test Package
11843Publisher: Example
11844License: MIT
11845ShortDescription: explicit-upgrade fixture
11846RequireExplicitUpgrade: true
11847Installers:
11848 - Architecture: x64
11849 InstallerType: exe
11850 InstallerUrl: https://example.test/Test.Package.exe
11851 InstallerSha256: ABC123
11852"#;
11853 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11854 assert!(manifest.require_explicit_upgrade);
11855 }
11856
11857 #[test]
11858 fn manifest_parses_require_explicit_upgrade_on_installer() {
11859 let yaml = r#"
11863PackageIdentifier: Test.Package
11864PackageVersion: 1.2.3
11865DefaultLocale: en-US
11866ManifestType: singleton
11867ManifestVersion: 1.10.0
11868PackageLocale: en-US
11869PackageName: Test Package
11870Publisher: Example
11871License: MIT
11872ShortDescription: explicit-upgrade fixture
11873Installers:
11874 - Architecture: x64
11875 InstallerType: exe
11876 InstallerUrl: https://example.test/Test.Package.x64.exe
11877 InstallerSha256: ABC123
11878 - Architecture: arm64
11879 InstallerType: exe
11880 InstallerUrl: https://example.test/Test.Package.arm64.exe
11881 InstallerSha256: DEF456
11882 RequireExplicitUpgrade: true
11883"#;
11884 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11885 assert!(manifest.require_explicit_upgrade);
11886 assert!(!manifest.installers[0].require_explicit_upgrade);
11887 assert!(manifest.installers[1].require_explicit_upgrade);
11888 }
11889
11890 #[test]
11891 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
11892 let yaml = r#"
11893PackageIdentifier: Test.Package
11894PackageVersion: 1.2.3
11895DefaultLocale: en-US
11896ManifestType: singleton
11897ManifestVersion: 1.10.0
11898PackageLocale: en-US
11899PackageName: Test Package
11900Publisher: Example
11901License: MIT
11902ShortDescription: baseline fixture
11903Installers:
11904 - Architecture: x64
11905 InstallerType: exe
11906 InstallerUrl: https://example.test/Test.Package.exe
11907 InstallerSha256: ABC123
11908"#;
11909 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11910 assert!(!manifest.require_explicit_upgrade);
11911 }
11912
11913 #[test]
11914 fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
11915 let yaml = r#"
11916PackageIdentifier: Test.Package
11917PackageVersion: 1.2.3
11918DefaultLocale: en-US
11919ManifestType: singleton
11920ManifestVersion: 1.10.0
11921PackageLocale: en-US
11922PackageName: Test Package
11923Publisher: Example
11924License: MIT
11925ShortDescription: icon fixture
11926Icons:
11927 - IconUrl: https://example.test/dark-512.png
11928 IconFileType: png
11929 IconResolution: 512x512
11930 IconTheme: dark
11931 IconSha256: DARK
11932 - IconUrl: https://example.test/default-64.png
11933 IconFileType: png
11934 IconResolution: 64x64
11935 IconTheme: default
11936 - IconUrl: https://example.test/default-256.ico
11937 IconFileType: ico
11938 IconResolution: 256x256
11939 IconTheme: default
11940 - IconFileType: png
11941 IconResolution: 1024x1024
11942Installers:
11943 - Architecture: x64
11944 InstallerType: exe
11945 InstallerUrl: https://example.test/Test.Package.exe
11946 InstallerSha256: ABC123
11947"#;
11948 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11949
11950 assert_eq!(manifest.icons.len(), 4);
11951 assert_eq!(
11952 manifest.icons[0].icon_url.as_deref(),
11953 Some("https://example.test/dark-512.png")
11954 );
11955 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11956 let selected = manifest.icon().expect("selected icon");
11957 assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
11958 assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
11959 }
11960
11961 #[test]
11962 fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
11963 let yaml = r#"
11964PackageIdentifier: Test.Package
11965PackageVersion: 1.2.3
11966DefaultLocale: en-US
11967ManifestType: singleton
11968ManifestVersion: 1.10.0
11969PackageLocale: en-US
11970PackageName: Test Package
11971Publisher: Example
11972License: MIT
11973ShortDescription: no-icon fixture
11974Installers:
11975 - Architecture: x64
11976 InstallerType: exe
11977 InstallerUrl: https://example.test/Test.Package.exe
11978 InstallerSha256: ABC123
11979"#;
11980 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11981
11982 assert!(manifest.icons.is_empty());
11983 assert!(manifest.icon().is_none());
11984 assert!(manifest.icon_url().is_none());
11985 }
11986
11987 #[test]
11988 fn rest_manifest_parses_icons_from_default_locale() {
11989 let response = serde_json::json!({
11990 "Data": {
11991 "PackageIdentifier": "Test.Package",
11992 "Versions": [
11993 {
11994 "PackageVersion": "1.2.3",
11995 "DefaultLocale": {
11996 "PackageName": "Test Package",
11997 "Publisher": "Example",
11998 "ShortDescription": "REST icon fixture",
11999 "Icons": [
12000 {
12001 "IconUrl": "https://example.test/default-128.png",
12002 "IconFileType": "png",
12003 "IconResolution": "128x128",
12004 "IconTheme": "default"
12005 },
12006 {
12007 "IconUrl": "https://example.test/default-256.ico",
12008 "IconFileType": "ico",
12009 "IconResolution": "256x256"
12010 }
12011 ]
12012 },
12013 "Installers": [
12014 {
12015 "Architecture": "x64",
12016 "InstallerType": "exe",
12017 "InstallerUrl": "https://example.test/Test.Package.exe",
12018 "InstallerSha256": "ABC123"
12019 }
12020 ],
12021 "ManifestVersion": "1.10.0"
12022 }
12023 ],
12024 "ManifestVersion": "1.10.0"
12025 }
12026 });
12027 let bytes = serde_json::to_vec(&response).expect("json");
12028
12029 let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
12030
12031 assert_eq!(manifest.icons.len(), 2);
12032 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
12033 assert_eq!(
12034 document["Icons"][0]["IconUrl"].as_str(),
12035 Some("https://example.test/default-128.png")
12036 );
12037 }
12038
12039 #[test]
12040 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
12041 let mut pkg = InstalledPackage {
12045 name: "Edge".to_owned(),
12046 local_id: r"ARP\Machine\X64\Edge".to_owned(),
12047 installed_version: "100.0".to_owned(),
12048 publisher: None,
12049 scope: Some("Machine".to_owned()),
12050 installer_category: Some("exe".to_owned()),
12051 install_location: None,
12052 package_family_names: Vec::new(),
12053 product_codes: Vec::new(),
12054 upgrade_codes: Vec::new(),
12055 correlated: Some(SearchMatch {
12056 source_name: "winget".to_owned(),
12057 source_kind: SourceKind::PreIndexed,
12058 id: "Microsoft.Edge".to_owned(),
12059 name: "Microsoft Edge".to_owned(),
12060 moniker: None,
12061 version: Some("110.0".to_owned()),
12062 channel: None,
12063 match_criteria: None,
12064 }),
12065 winget_package_identifier: None,
12066 winget_source_identifier: None,
12067 installed_version_canonical: false,
12068 correlated_requires_explicit_upgrade: true,
12069 correlated_lacks_compatible_installer: false,
12070 };
12071 let bulk_query = ListQuery {
12072 upgrade_only: true,
12073 ..ListQuery::default()
12074 };
12075 assert!(
12076 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12077 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12078 );
12079
12080 let filtered_query = ListQuery {
12083 upgrade_only: true,
12084 id: Some("Microsoft.Edge".to_owned()),
12085 ..ListQuery::default()
12086 };
12087 assert!(
12088 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12089 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12090 );
12091
12092 pkg.correlated_requires_explicit_upgrade = false;
12094 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12095 }
12096
12097 #[test]
12098 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12099 let mut pkg = InstalledPackage {
12100 name: "Foo".to_owned(),
12101 local_id: r"ARP\Machine\X64\Foo".to_owned(),
12102 installed_version: "1.0".to_owned(),
12103 publisher: None,
12104 scope: Some("Machine".to_owned()),
12105 installer_category: Some("exe".to_owned()),
12106 install_location: None,
12107 package_family_names: Vec::new(),
12108 product_codes: Vec::new(),
12109 upgrade_codes: Vec::new(),
12110 correlated: Some(SearchMatch {
12111 source_name: "winget".to_owned(),
12112 source_kind: SourceKind::PreIndexed,
12113 id: "Test.Foo".to_owned(),
12114 name: "Foo".to_owned(),
12115 moniker: None,
12116 version: Some("2.0".to_owned()),
12117 channel: None,
12118 match_criteria: None,
12119 }),
12120 winget_package_identifier: None,
12121 winget_source_identifier: None,
12122 installed_version_canonical: false,
12123 correlated_requires_explicit_upgrade: false,
12124 correlated_lacks_compatible_installer: true,
12125 };
12126 let bulk_query = ListQuery {
12127 upgrade_only: true,
12128 ..ListQuery::default()
12129 };
12130 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12131
12132 let filtered_query = ListQuery {
12134 upgrade_only: true,
12135 id: Some("Test.Foo".to_owned()),
12136 ..ListQuery::default()
12137 };
12138 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12139
12140 pkg.correlated_lacks_compatible_installer = false;
12142 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12143 }
12144
12145 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12146 let installers = arches
12147 .iter()
12148 .map(|a| Installer {
12149 architecture: Some((*a).to_owned()),
12150 installer_type: None,
12151 url: None,
12152 sha256: None,
12153 product_code: None,
12154 locale: None,
12155 scope: None,
12156 release_date: None,
12157 package_family_name: None,
12158 upgrade_code: None,
12159 platforms: Vec::new(),
12160 minimum_os_version: None,
12161 switches: InstallerSwitches::default(),
12162 commands: Vec::new(),
12163 package_dependencies: Vec::new(),
12164 require_explicit_upgrade: false,
12165 nested_installer_type: None,
12166 nested_installer_files: Vec::new(),
12167 })
12168 .collect();
12169 Manifest {
12170 id: "Test".to_owned(),
12171 name: "Test".to_owned(),
12172 version: "1.0".to_owned(),
12173 channel: String::new(),
12174 publisher: None,
12175 description: None,
12176 moniker: None,
12177 package_url: None,
12178 publisher_url: None,
12179 publisher_support_url: None,
12180 license: None,
12181 license_url: None,
12182 privacy_url: None,
12183 author: None,
12184 copyright: None,
12185 copyright_url: None,
12186 release_notes: None,
12187 release_notes_url: None,
12188 tags: Vec::new(),
12189 agreements: Vec::new(),
12190 package_dependencies: Vec::new(),
12191 documentation: Vec::new(),
12192 icons: Vec::new(),
12193 installers,
12194 require_explicit_upgrade: false,
12195 }
12196 }
12197
12198 #[test]
12199 fn manifest_compatible_installer_neutral_always_matches() {
12200 assert!(manifest_has_compatible_installer(
12202 &synthetic_manifest_with_installer_arches(&["neutral"])
12203 ));
12204 }
12205
12206 #[test]
12207 fn manifest_compatible_installer_empty_installers_pass_through() {
12208 assert!(manifest_has_compatible_installer(
12211 &synthetic_manifest_with_installer_arches(&[])
12212 ));
12213 }
12214
12215 #[test]
12216 fn manifest_compatible_installer_rejects_alien_arch_only() {
12217 assert!(!manifest_has_compatible_installer(
12219 &synthetic_manifest_with_installer_arches(&["ppc"])
12220 ));
12221 }
12222
12223 #[test]
12224 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12225 assert!(manifest_has_compatible_installer(
12227 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12228 ));
12229 }
12230
12231 #[test]
12232 fn lookup_unique_normalized_identity_returns_unique_match() {
12233 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12235 execute_batch_sql(
12236 &connection,
12237 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12238 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12239 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12240 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12241 )
12242 .expect("seed schema");
12243
12244 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12245 .expect("query")
12246 .expect("unique match");
12247 assert_eq!(rowid, 100);
12248 }
12249
12250 #[test]
12251 fn lookup_installed_db_identity_match_returns_product_code_match() {
12252 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12253 execute_batch_sql(
12254 &connection,
12255 "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12256 CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12257 CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12258 CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12259 CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12260 INSERT INTO ids VALUES (1, 'Git.Git');\n\
12261 INSERT INTO names VALUES (1, 'Git');\n\
12262 INSERT INTO manifest VALUES (10, 1, 1);\n\
12263 INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12264 INSERT INTO productcodes_map VALUES (10, 20);",
12265 )
12266 .expect("seed schema");
12267 let package = InstalledPackage {
12268 name: "Git".to_owned(),
12269 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12270 installed_version: "2.54.0".to_owned(),
12271 publisher: None,
12272 scope: None,
12273 installer_category: None,
12274 install_location: None,
12275 package_family_names: Vec::new(),
12276 product_codes: vec!["git_is1".to_owned()],
12277 upgrade_codes: Vec::new(),
12278 correlated: None,
12279 winget_package_identifier: None,
12280 winget_source_identifier: None,
12281 installed_version_canonical: false,
12282 correlated_requires_explicit_upgrade: false,
12283 correlated_lacks_compatible_installer: false,
12284 };
12285
12286 let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12287 .expect("query")
12288 .expect("product code match");
12289
12290 assert_eq!(id, "Git.Git");
12291 assert_eq!(name, "Git");
12292 assert_eq!(matched_by, "ProductCode");
12293 }
12294
12295 #[test]
12296 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12297 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12302 execute_batch_sql(
12303 &connection,
12304 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12305 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12306 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12307 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12308 )
12309 .expect("seed schema");
12310
12311 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12312 assert!(rowid.is_none(), "ambiguous match must not correlate");
12313 }
12314
12315 #[test]
12316 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12317 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12321 execute_batch_sql(
12322 &connection,
12323 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12324 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12325 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12326 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12327 )
12328 .expect("seed schema");
12329
12330 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12331 assert_eq!(rowid, Some(100));
12332 }
12333
12334 #[test]
12335 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12336 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12339 execute_batch_sql(
12340 &connection,
12341 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12342 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12343 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12344 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12345 )
12346 .expect("seed schema");
12347
12348 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12349 assert!(
12350 rowid.is_none(),
12351 "name matches package 100 but publisher matches 200 — no intersection"
12352 );
12353 }
12354
12355 #[test]
12356 fn parse_yaml_manifest_bundle_returns_singleton_document() {
12357 let yaml = r#"
12358PackageIdentifier: Test.Package
12359PackageVersion: 1.2.3
12360DefaultLocale: en-US
12361ManifestType: version
12362ManifestVersion: 1.10.0
12363---
12364PackageIdentifier: Test.Package
12365PackageVersion: 1.2.3
12366PackageLocale: en-US
12367PackageName: Test Package
12368Publisher: Example
12369License: MIT
12370ShortDescription: Structured output
12371ManifestType: defaultLocale
12372ManifestVersion: 1.10.0
12373---
12374PackageIdentifier: Test.Package
12375PackageVersion: 1.2.3
12376ManifestType: installer
12377ManifestVersion: 1.10.0
12378Installers:
12379 - Architecture: x64
12380 InstallerType: exe
12381 InstallerUrl: https://example.test/Test.Package.exe
12382 InstallerSha256: ABC123
12383"#;
12384
12385 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12386
12387 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12388 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12389 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12390 }
12391
12392 #[test]
12393 fn collapse_structured_documents_returns_plural_show_documents() {
12394 let documents = collapse_structured_documents(&[
12395 JsonValue::Array(vec![
12396 serde_json::json!({
12397 "PackageIdentifier": "Test.Package.One",
12398 "PackageVersion": "1.0.0",
12399 "DefaultLocale": "en-US",
12400 "ManifestType": "version",
12401 "ManifestVersion": "1.10.0"
12402 }),
12403 serde_json::json!({
12404 "PackageIdentifier": "Test.Package.One",
12405 "PackageVersion": "1.0.0",
12406 "PackageLocale": "en-US",
12407 "PackageName": "Test Package One",
12408 "ManifestType": "defaultLocale",
12409 "ManifestVersion": "1.10.0"
12410 }),
12411 serde_json::json!({
12412 "PackageIdentifier": "Test.Package.One",
12413 "PackageVersion": "1.0.0",
12414 "ManifestType": "installer",
12415 "ManifestVersion": "1.10.0",
12416 "Installers": [
12417 {
12418 "Architecture": "x64",
12419 "InstallerType": "exe",
12420 "InstallerUrl": "https://example.test/one.exe",
12421 "InstallerSha256": "ABC123"
12422 }
12423 ]
12424 }),
12425 ]),
12426 serde_json::json!({
12427 "PackageIdentifier": "Test.Package.Two",
12428 "PackageVersion": "2.0.0",
12429 "PackageLocale": "en-US",
12430 "PackageName": "Test Package Two",
12431 "ManifestType": "singleton",
12432 "ManifestVersion": "1.12.0"
12433 }),
12434 ]);
12435
12436 assert_eq!(documents.len(), 2);
12437 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12438 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12439 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12440 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12441 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12442 }
12443
12444 #[test]
12445 fn unsupported_action_result_marks_noop_and_warning() {
12446 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12447
12448 assert!(result.success);
12449 assert!(result.no_op);
12450 assert_eq!(result.exit_code, 0);
12451 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12452 }
12453
12454 #[test]
12455 fn create_install_no_op_result_honors_no_upgrade() {
12456 let mut request = InstallRequest::new(PackageQuery {
12457 id: Some("Contoso.App".to_owned()),
12458 ..PackageQuery::default()
12459 });
12460 request.no_upgrade = true;
12461
12462 let manifest = Manifest {
12463 id: "Contoso.App".to_owned(),
12464 name: "Contoso App".to_owned(),
12465 version: "2.0.0".to_owned(),
12466 channel: String::new(),
12467 publisher: None,
12468 description: None,
12469 moniker: None,
12470 package_url: None,
12471 publisher_url: None,
12472 publisher_support_url: None,
12473 license: None,
12474 license_url: None,
12475 privacy_url: None,
12476 author: None,
12477 copyright: None,
12478 copyright_url: None,
12479 release_notes: None,
12480 release_notes_url: None,
12481 tags: Vec::new(),
12482 agreements: Vec::new(),
12483 package_dependencies: Vec::new(),
12484 documentation: Vec::new(),
12485 icons: Vec::new(),
12486 installers: Vec::new(),
12487 require_explicit_upgrade: false,
12488 };
12489 let existing = ListMatch {
12490 name: "Contoso App".to_owned(),
12491 id: "Contoso.App".to_owned(),
12492 local_id: "Contoso.App".to_owned(),
12493 installed_version: "1.0.0".to_owned(),
12494 available_version: Some("2.0.0".to_owned()),
12495 source_name: Some("winget".to_owned()),
12496 publisher: None,
12497 scope: None,
12498 installer_category: None,
12499 install_location: None,
12500 package_family_names: Vec::new(),
12501 product_codes: Vec::new(),
12502 upgrade_codes: Vec::new(),
12503 };
12504
12505 let result =
12506 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12507
12508 assert!(result.success);
12509 assert!(result.no_op);
12510 assert_eq!(result.version, "1.0.0");
12511 assert_eq!(
12512 result.warnings,
12513 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12514 );
12515 }
12516
12517 #[test]
12518 fn create_install_no_op_result_skips_reinstall_when_current() {
12519 let request = InstallRequest::new(PackageQuery {
12520 id: Some("Contoso.App".to_owned()),
12521 ..PackageQuery::default()
12522 });
12523
12524 let manifest = Manifest {
12525 id: "Contoso.App".to_owned(),
12526 name: "Contoso App".to_owned(),
12527 version: "2.0.0".to_owned(),
12528 channel: String::new(),
12529 publisher: None,
12530 description: None,
12531 moniker: None,
12532 package_url: None,
12533 publisher_url: None,
12534 publisher_support_url: None,
12535 license: None,
12536 license_url: None,
12537 privacy_url: None,
12538 author: None,
12539 copyright: None,
12540 copyright_url: None,
12541 release_notes: None,
12542 release_notes_url: None,
12543 tags: Vec::new(),
12544 agreements: Vec::new(),
12545 package_dependencies: Vec::new(),
12546 documentation: Vec::new(),
12547 icons: Vec::new(),
12548 installers: Vec::new(),
12549 require_explicit_upgrade: false,
12550 };
12551 let existing = ListMatch {
12552 name: "Contoso App".to_owned(),
12553 id: "Contoso.App".to_owned(),
12554 local_id: "Contoso.App".to_owned(),
12555 installed_version: "2.0.0".to_owned(),
12556 available_version: Some("2.0.0".to_owned()),
12557 source_name: Some("winget".to_owned()),
12558 publisher: None,
12559 scope: None,
12560 installer_category: None,
12561 install_location: None,
12562 package_family_names: Vec::new(),
12563 product_codes: Vec::new(),
12564 upgrade_codes: Vec::new(),
12565 };
12566
12567 let result =
12568 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12569
12570 assert!(result.success);
12571 assert!(result.no_op);
12572 assert_eq!(result.version, "2.0.0");
12573 assert_eq!(
12574 result.warnings,
12575 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
12576 );
12577 }
12578
12579 #[test]
12580 fn collapse_structured_document_projects_merged_manifest() {
12581 let document = collapse_structured_document(&serde_json::json!({
12582 "PackageIdentifier": "Test.Package",
12583 "PackageVersion": "1.2.3",
12584 "PackageLocale": "en-US",
12585 "PackageName": "Test Package",
12586 "Publisher": "Example",
12587 "InstallerType": "exe",
12588 "Installers": [
12589 {
12590 "Architecture": "x64",
12591 "InstallerUrl": "https://example.test/Test.Package.exe",
12592 "InstallerSha256": "ABC123"
12593 }
12594 ],
12595 "ManifestType": "merged",
12596 "ManifestVersion": "1.10.0"
12597 }));
12598
12599 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12600 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
12601 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
12602 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
12603 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
12604 }
12605
12606 #[test]
12607 fn parses_fixture_manifest() {
12608 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
12609 .join("tests")
12610 .join("fixtures")
12611 .join("ManifestV1_28-Singleton.yaml");
12612 let bytes = fs::read(root).expect("fixture bytes");
12613 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
12614 assert_eq!(manifest.id, "microsoft.msixsdk");
12615 assert_eq!(manifest.version, "1.7.32");
12616 assert_eq!(manifest.name, "MSIX SDK");
12617 assert!(!manifest.installers.is_empty());
12618 }
12619
12620 #[test]
12621 fn parses_installer_switches_from_yaml_manifest() {
12622 let yaml = r#"
12623PackageIdentifier: Test.Package
12624PackageVersion: 1.2.3
12625PackageName: Test Package
12626InstallerSwitches:
12627 SilentWithProgress: /SILENT
12628Installers:
12629 - Architecture: x64
12630 InstallerType: inno
12631 InstallerUrl: https://example.test/Test.Package.exe
12632 InstallerSha256: ABC123
12633 InstallerSwitches:
12634 Silent: /VERYSILENT
12635 Interactive: /HELP
12636"#;
12637
12638 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12639 let installer = &manifest.installers[0];
12640
12641 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
12642 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
12643 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
12644 }
12645
12646 #[test]
12647 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
12648 let yaml = r#"
12649PackageIdentifier: Test.Package
12650PackageVersion: 1.2.3
12651PackageName: Test Package
12652Platform:
12653 - Windows.Desktop
12654MinimumOSVersion: 10.0.19041.0
12655Installers:
12656 - Architecture: x64
12657 InstallerType: exe
12658 InstallerUrl: https://example.test/Test.Package.exe
12659 InstallerSha256: ABC123
12660 - Architecture: x64
12661 InstallerType: msix
12662 Platform:
12663 - Windows.Universal
12664 MinimumOSVersion: 10.0.22621.0
12665 InstallerUrl: https://example.test/Test.Package.msix
12666 InstallerSha256: DEF456
12667"#;
12668
12669 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12670
12671 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
12672 assert_eq!(
12673 manifest.installers[0].minimum_os_version.as_deref(),
12674 Some("10.0.19041.0")
12675 );
12676 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
12677 assert_eq!(
12678 manifest.installers[1].minimum_os_version.as_deref(),
12679 Some("10.0.22621.0")
12680 );
12681 }
12682
12683 #[test]
12684 fn installer_switch_arguments_prefer_manifest_switches() {
12685 let installer = Installer {
12686 architecture: None,
12687 installer_type: Some("inno".to_owned()),
12688 url: None,
12689 sha256: None,
12690 product_code: None,
12691 locale: None,
12692 scope: None,
12693 release_date: None,
12694 package_family_name: None,
12695 upgrade_code: None,
12696 platforms: Vec::new(),
12697 minimum_os_version: None,
12698 switches: InstallerSwitches {
12699 silent: Some("/mysilent".to_owned()),
12700 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
12701 interactive: Some("/myinteractive".to_owned()),
12702 ..InstallerSwitches::default()
12703 },
12704 commands: Vec::new(),
12705 package_dependencies: Vec::new(),
12706 require_explicit_upgrade: false,
12707 nested_installer_type: None,
12708 nested_installer_files: Vec::new(),
12709 };
12710 let manifest = Manifest {
12711 id: "Test.Package".to_owned(),
12712 name: "Test Package".to_owned(),
12713 version: "1.0.0".to_owned(),
12714 channel: String::new(),
12715 publisher: None,
12716 description: None,
12717 moniker: None,
12718 package_url: None,
12719 publisher_url: None,
12720 publisher_support_url: None,
12721 license: None,
12722 license_url: None,
12723 privacy_url: None,
12724 author: None,
12725 copyright: None,
12726 copyright_url: None,
12727 release_notes: None,
12728 release_notes_url: None,
12729 tags: Vec::new(),
12730 agreements: Vec::new(),
12731 package_dependencies: Vec::new(),
12732 documentation: Vec::new(),
12733 icons: Vec::new(),
12734 installers: Vec::new(),
12735 require_explicit_upgrade: false,
12736 };
12737
12738 let mut silent_request = InstallRequest::new(PackageQuery::default());
12739 silent_request.mode = InstallerMode::Silent;
12740 let mut progress_request = InstallRequest::new(PackageQuery::default());
12741 progress_request.mode = InstallerMode::SilentWithProgress;
12742 let mut interactive_request = InstallRequest::new(PackageQuery::default());
12743 interactive_request.mode = InstallerMode::Interactive;
12744 assert_eq!(
12745 installer_command_arguments(
12746 "inno",
12747 &silent_request,
12748 &manifest,
12749 Path::new("installer.exe"),
12750 &installer
12751 ),
12752 vec!["/mysilent".to_owned()]
12753 );
12754 assert_eq!(
12755 installer_command_arguments(
12756 "inno",
12757 &progress_request,
12758 &manifest,
12759 Path::new("installer.exe"),
12760 &installer
12761 ),
12762 vec!["/mysilentwithprogress".to_owned()]
12763 );
12764 assert_eq!(
12765 installer_command_arguments(
12766 "inno",
12767 &interactive_request,
12768 &manifest,
12769 Path::new("installer.exe"),
12770 &installer
12771 ),
12772 vec!["/myinteractive".to_owned()]
12773 );
12774 }
12775
12776 #[test]
12777 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
12778 let installer = Installer {
12779 architecture: None,
12780 installer_type: Some("inno".to_owned()),
12781 url: None,
12782 sha256: None,
12783 product_code: None,
12784 locale: None,
12785 scope: None,
12786 release_date: None,
12787 package_family_name: None,
12788 upgrade_code: None,
12789 platforms: Vec::new(),
12790 minimum_os_version: None,
12791 switches: InstallerSwitches::default(),
12792 commands: Vec::new(),
12793 package_dependencies: Vec::new(),
12794 require_explicit_upgrade: false,
12795 nested_installer_type: None,
12796 nested_installer_files: Vec::new(),
12797 };
12798 let manifest = Manifest {
12799 id: "Test.Package".to_owned(),
12800 name: "Test Package".to_owned(),
12801 version: "1.0.0".to_owned(),
12802 channel: String::new(),
12803 publisher: None,
12804 description: None,
12805 moniker: None,
12806 package_url: None,
12807 publisher_url: None,
12808 publisher_support_url: None,
12809 license: None,
12810 license_url: None,
12811 privacy_url: None,
12812 author: None,
12813 copyright: None,
12814 copyright_url: None,
12815 release_notes: None,
12816 release_notes_url: None,
12817 tags: Vec::new(),
12818 agreements: Vec::new(),
12819 package_dependencies: Vec::new(),
12820 documentation: Vec::new(),
12821 icons: Vec::new(),
12822 installers: Vec::new(),
12823 require_explicit_upgrade: false,
12824 };
12825 let mut progress_request = InstallRequest::new(PackageQuery::default());
12826 progress_request.mode = InstallerMode::SilentWithProgress;
12827 let mut silent_request = InstallRequest::new(PackageQuery::default());
12828 silent_request.mode = InstallerMode::Silent;
12829
12830 assert_eq!(
12831 installer_command_arguments(
12832 "inno",
12833 &progress_request,
12834 &manifest,
12835 Path::new("installer.exe"),
12836 &installer
12837 ),
12838 vec![
12839 "/SP-".to_owned(),
12840 "/SILENT".to_owned(),
12841 "/SUPPRESSMSGBOXES".to_owned(),
12842 "/NORESTART".to_owned()
12843 ]
12844 );
12845 assert_eq!(
12846 installer_command_arguments(
12847 "inno",
12848 &silent_request,
12849 &manifest,
12850 Path::new("installer.exe"),
12851 &installer
12852 ),
12853 vec![
12854 "/SP-".to_owned(),
12855 "/VERYSILENT".to_owned(),
12856 "/SUPPRESSMSGBOXES".to_owned(),
12857 "/NORESTART".to_owned()
12858 ]
12859 );
12860 }
12861
12862 #[test]
12863 fn installer_command_arguments_append_manifest_and_cli_switches() {
12864 let installer = Installer {
12865 architecture: None,
12866 installer_type: Some("msi".to_owned()),
12867 url: None,
12868 sha256: None,
12869 product_code: None,
12870 locale: None,
12871 scope: None,
12872 release_date: None,
12873 package_family_name: None,
12874 upgrade_code: None,
12875 platforms: Vec::new(),
12876 minimum_os_version: None,
12877 switches: InstallerSwitches {
12878 custom: Some("ADDLOCAL=Core".to_owned()),
12879 log: Some("/log \"<LOGPATH>\"".to_owned()),
12880 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
12881 ..InstallerSwitches::default()
12882 },
12883 commands: Vec::new(),
12884 package_dependencies: Vec::new(),
12885 require_explicit_upgrade: false,
12886 nested_installer_type: None,
12887 nested_installer_files: Vec::new(),
12888 };
12889 let manifest = Manifest {
12890 id: "ShareX.ShareX".to_owned(),
12891 name: "ShareX".to_owned(),
12892 version: "19.0.2".to_owned(),
12893 channel: String::new(),
12894 publisher: None,
12895 description: None,
12896 moniker: None,
12897 package_url: None,
12898 publisher_url: None,
12899 publisher_support_url: None,
12900 license: None,
12901 license_url: None,
12902 privacy_url: None,
12903 author: None,
12904 copyright: None,
12905 copyright_url: None,
12906 release_notes: None,
12907 release_notes_url: None,
12908 tags: Vec::new(),
12909 agreements: Vec::new(),
12910 package_dependencies: Vec::new(),
12911 documentation: Vec::new(),
12912 icons: Vec::new(),
12913 installers: Vec::new(),
12914 require_explicit_upgrade: false,
12915 };
12916 let mut request = InstallRequest::new(PackageQuery::default());
12917 request.mode = InstallerMode::Silent;
12918 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
12919 request.custom = Some("REBOOT=ReallySuppress".to_owned());
12920 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
12921
12922 assert_eq!(
12923 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
12924 vec![
12925 "/i".to_owned(),
12926 r"C:\temp\ShareX.msi".to_owned(),
12927 "/quiet".to_owned(),
12928 "/norestart".to_owned(),
12929 "/log".to_owned(),
12930 r"C:\temp\winget.log".to_owned(),
12931 "ADDLOCAL=Core".to_owned(),
12932 "REBOOT=ReallySuppress".to_owned(),
12933 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
12934 ]
12935 );
12936 }
12937
12938 #[test]
12939 fn msi_shell_execute_arguments_include_msiexec_target() {
12940 let installer = Installer {
12941 architecture: None,
12942 installer_type: Some("wix".to_owned()),
12943 url: None,
12944 sha256: None,
12945 product_code: None,
12946 locale: None,
12947 scope: None,
12948 release_date: None,
12949 package_family_name: None,
12950 upgrade_code: None,
12951 platforms: Vec::new(),
12952 minimum_os_version: None,
12953 switches: InstallerSwitches {
12954 log: Some("/log \"<LOGPATH>\"".to_owned()),
12955 install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
12956 ..InstallerSwitches::default()
12957 },
12958 commands: Vec::new(),
12959 package_dependencies: Vec::new(),
12960 require_explicit_upgrade: false,
12961 nested_installer_type: None,
12962 nested_installer_files: Vec::new(),
12963 };
12964 let mut request = InstallRequest::new(PackageQuery::default());
12965 request.mode = InstallerMode::Silent;
12966 request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
12967 request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
12968
12969 assert_eq!(
12970 installer_shell_execute_arguments(
12971 "wix",
12972 &request,
12973 &Manifest {
12974 id: "OpenJS.NodeJS.22".to_owned(),
12975 name: "Node.js".to_owned(),
12976 version: "22.22.3".to_owned(),
12977 channel: String::new(),
12978 publisher: None,
12979 description: None,
12980 moniker: None,
12981 package_url: None,
12982 publisher_url: None,
12983 publisher_support_url: None,
12984 license: None,
12985 license_url: None,
12986 privacy_url: None,
12987 author: None,
12988 copyright: None,
12989 copyright_url: None,
12990 release_notes: None,
12991 release_notes_url: None,
12992 tags: Vec::new(),
12993 agreements: Vec::new(),
12994 package_dependencies: Vec::new(),
12995 documentation: Vec::new(),
12996 icons: Vec::new(),
12997 installers: Vec::new(),
12998 require_explicit_upgrade: false,
12999 },
13000 Path::new(r"C:\temp\node.msi"),
13001 &installer
13002 ),
13003 vec![
13004 "/i".to_owned(),
13005 r"C:\temp\node.msi".to_owned(),
13006 "/quiet".to_owned(),
13007 "/norestart".to_owned(),
13008 "/log".to_owned(),
13009 r"C:\temp\node.log".to_owned(),
13010 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13011 ]
13012 );
13013 }
13014
13015 #[test]
13016 fn msi_direct_arguments_omit_msiexec_target() {
13017 let installer = Installer::default();
13018 let mut request = InstallRequest::new(PackageQuery::default());
13019 request.mode = InstallerMode::Silent;
13020
13021 assert_eq!(
13022 installer_direct_msi_arguments(
13023 "wix",
13024 &request,
13025 &Manifest {
13026 id: "Test.Package".to_owned(),
13027 name: "Test".to_owned(),
13028 version: "1.0.0".to_owned(),
13029 channel: String::new(),
13030 publisher: None,
13031 description: None,
13032 moniker: None,
13033 package_url: None,
13034 publisher_url: None,
13035 publisher_support_url: None,
13036 license: None,
13037 license_url: None,
13038 privacy_url: None,
13039 author: None,
13040 copyright: None,
13041 copyright_url: None,
13042 release_notes: None,
13043 release_notes_url: None,
13044 tags: Vec::new(),
13045 agreements: Vec::new(),
13046 package_dependencies: Vec::new(),
13047 documentation: Vec::new(),
13048 icons: Vec::new(),
13049 installers: Vec::new(),
13050 require_explicit_upgrade: false,
13051 },
13052 Path::new(r"C:\temp\test.msi"),
13053 &installer
13054 ),
13055 vec!["/quiet".to_owned(), "/norestart".to_owned()]
13056 );
13057 }
13058
13059 #[test]
13060 fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
13061 let args = vec![
13062 "/quiet".to_owned(),
13063 "/norestart".to_owned(),
13064 "/log".to_owned(),
13065 r"C:\temp\node.log".to_owned(),
13066 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13067 ];
13068
13069 let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13070
13071 assert_eq!(parsed.ui_level, 0x102);
13072 assert_eq!(
13073 parsed.properties,
13074 r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13075 );
13076 assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13077 }
13078
13079 #[cfg(windows)]
13080 #[test]
13081 fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13082 let mut request = InstallRequest::new(PackageQuery::default());
13083 request.mode = InstallerMode::Silent;
13084
13085 let installer = Installer {
13086 installer_type: Some("wix".to_owned()),
13087 scope: Some("machine".to_owned()),
13088 ..Installer::default()
13089 };
13090
13091 assert!(!should_run_msi_direct(&request, &installer));
13092 assert!(should_elevate_msi_shell_execute(&installer));
13093 }
13094
13095 #[test]
13096 fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13097 let strong = InstallerDownloadMetadata {
13098 url: "https://example.test/app.exe".to_owned(),
13099 etag: Some(r#""abc""#.to_owned()),
13100 last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13101 total_length: Some(100),
13102 offset: 50,
13103 };
13104 let weak = InstallerDownloadMetadata {
13105 etag: Some(r#"W/"abc""#.to_owned()),
13106 ..strong.clone()
13107 };
13108
13109 assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13110 assert_eq!(
13111 installer_download_if_range(&weak).as_deref(),
13112 Some("Wed, 03 Jun 2026 21:00:00 GMT")
13113 );
13114 }
13115
13116 #[test]
13117 fn parse_content_range_validates_resume_start_and_total() {
13118 let value = HeaderValue::from_static("bytes 50-99/100");
13119 let parsed = parse_content_range(Some(&value), 50).expect("content range");
13120
13121 assert_eq!(parsed.total_length, Some(100));
13122 assert!(parse_content_range(Some(&value), 49).is_err());
13123 }
13124
13125 #[test]
13126 fn installer_download_sidecar_paths_append_suffixes() {
13127 let path = Path::new(r"C:\temp\installer.msi");
13128
13129 assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13130 assert_eq!(
13131 sidecar_path(path, "part.json"),
13132 PathBuf::from(r"C:\temp\installer.msi.part.json")
13133 );
13134 }
13135
13136 #[test]
13137 fn shell_execute_argument_joining_quotes_spaces() {
13138 assert_eq!(
13139 join_windows_arguments(&[
13140 "/quiet".to_owned(),
13141 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13142 r#"VALUE=has "quotes""#.to_owned(),
13143 ]),
13144 r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13145 );
13146 }
13147
13148 #[test]
13149 fn decompresses_mszyml_payload() {
13150 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13151 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13152 encoder.write_all(payload.as_bytes()).expect("write payload");
13153 let compressed = encoder.finish().expect("finish payload");
13154 let mut mszip = b"CK".to_vec();
13155 mszip.extend_from_slice(&compressed);
13156
13157 let decompressed = decompress_mszyml(&mszip).expect("decompress");
13158 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13159 assert_eq!(parsed.versions[0].version, "1.2.3");
13160 }
13161
13162 #[test]
13163 fn decompresses_mszyml_payload_with_prefix() {
13164 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13165 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13166 encoder.write_all(payload.as_bytes()).expect("write payload");
13167 let compressed = encoder.finish().expect("finish payload");
13168 let mut prefixed = vec![0u8; 28];
13169 prefixed.extend_from_slice(b"CK");
13170 prefixed.extend_from_slice(&compressed);
13171
13172 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13173 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13174 assert_eq!(parsed.versions[0].version, "1.2.3");
13175 }
13176
13177 #[test]
13178 fn compares_versions_naturally() {
13179 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13180 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13181 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13182 assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13183 assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13184 }
13185
13186 #[test]
13187 fn installer_hash_verification_fails_on_mismatch_without_override() {
13188 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13189 assert!(result.is_err());
13190 assert!(
13191 result
13192 .expect_err("hash mismatch")
13193 .to_string()
13194 .contains("Installer hash mismatch")
13195 );
13196 }
13197
13198 #[test]
13199 fn installer_hash_verification_allows_mismatch_with_override() {
13200 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13201 verify_installer_hash(None, b"pinget", false).expect("missing hash");
13202 }
13203
13204 #[test]
13205 fn search_query_many_includes_tag_and_command_conditions() {
13206 let query = PackageQuery {
13207 query: Some("terminal".to_owned()),
13208 ..PackageQuery::default()
13209 };
13210
13211 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13212
13213 assert!(where_clause.contains("tags2"));
13214 assert!(where_clause.contains("commands2"));
13215 assert_eq!(params.len(), 5);
13216 }
13217
13218 #[test]
13219 fn tag_and_command_conditions_are_non_correlated() {
13220 let query = PackageQuery {
13227 query: Some("terminal".to_owned()),
13228 ..PackageQuery::default()
13229 };
13230
13231 let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13232
13233 assert!(where_clause.contains("packages.rowid IN (SELECT"));
13234 assert!(!where_clause.contains("EXISTS"));
13235 assert!(!where_clause.contains("= packages.rowid"));
13236 }
13237
13238 #[test]
13239 fn show_query_single_omits_tag_and_command_conditions() {
13240 let query = PackageQuery {
13241 query: Some("terminal".to_owned()),
13242 ..PackageQuery::default()
13243 };
13244
13245 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13246
13247 assert!(!where_clause.contains("tags2"));
13248 assert!(!where_clause.contains("commands2"));
13249 assert_eq!(params.len(), 3);
13250 }
13251
13252 #[test]
13253 fn explicit_tag_filter_uses_exact_match() {
13254 let query = PackageQuery {
13255 tag: Some("terminal".to_owned()),
13256 ..PackageQuery::default()
13257 };
13258
13259 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13260
13261 assert!(where_clause.contains("tags2"));
13262 assert_eq!(params, vec!["terminal".to_owned()]);
13263 }
13264
13265 #[test]
13266 fn rest_tag_filter_uses_exact_match() {
13267 let query = PackageQuery {
13268 tag: Some("terminal".to_owned()),
13269 ..PackageQuery::default()
13270 };
13271 let info = RestInformation {
13272 required_package_match_fields: Vec::new(),
13273 unsupported_package_match_fields: Vec::new(),
13274 required_query_parameters: Vec::new(),
13275 ..RestInformation::default()
13276 };
13277
13278 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13279 let filters = body["Filters"].as_array().expect("filters");
13280
13281 assert_eq!(filters.len(), 1);
13282 assert_eq!(filters[0]["PackageMatchField"], "Tag");
13283 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13284 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13285 }
13286
13287 #[test]
13288 fn selects_installer_using_requested_filters() {
13289 let installers = vec![
13290 Installer {
13291 architecture: Some("x86".to_owned()),
13292 installer_type: Some("zip".to_owned()),
13293 url: None,
13294 sha256: None,
13295 product_code: None,
13296 locale: Some("en-US".to_owned()),
13297 scope: Some("user".to_owned()),
13298 release_date: None,
13299 package_family_name: None,
13300 upgrade_code: None,
13301 platforms: Vec::new(),
13302 minimum_os_version: None,
13303 switches: InstallerSwitches::default(),
13304 commands: Vec::new(),
13305 package_dependencies: Vec::new(),
13306 require_explicit_upgrade: false,
13307 nested_installer_type: None,
13308 nested_installer_files: Vec::new(),
13309 },
13310 Installer {
13311 architecture: Some("x64".to_owned()),
13312 installer_type: Some("msix".to_owned()),
13313 url: None,
13314 sha256: None,
13315 product_code: None,
13316 locale: Some("en-US".to_owned()),
13317 scope: Some("user".to_owned()),
13318 release_date: None,
13319 package_family_name: None,
13320 upgrade_code: None,
13321 platforms: Vec::new(),
13322 minimum_os_version: None,
13323 switches: InstallerSwitches::default(),
13324 commands: vec!["demo".to_owned()],
13325 package_dependencies: Vec::new(),
13326 require_explicit_upgrade: false,
13327 nested_installer_type: None,
13328 nested_installer_files: Vec::new(),
13329 },
13330 ];
13331 let query = PackageQuery {
13332 installer_type: Some("msix".to_owned()),
13333 installer_architecture: Some("x64".to_owned()),
13334 install_scope: Some("user".to_owned()),
13335 locale: Some("en-US".to_owned()),
13336 ..PackageQuery::default()
13337 };
13338
13339 let selected = select_installer(&installers, &query).expect("selected installer");
13340 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13341 assert_eq!(selected.architecture.as_deref(), Some("x64"));
13342 }
13343
13344 #[test]
13345 fn selects_first_installer_when_rank_ties() {
13346 let installers = vec![
13347 Installer {
13348 architecture: Some("x64".to_owned()),
13349 installer_type: Some("exe".to_owned()),
13350 url: None,
13351 sha256: None,
13352 product_code: None,
13353 locale: Some("en-US".to_owned()),
13354 scope: Some("machine".to_owned()),
13355 release_date: None,
13356 package_family_name: None,
13357 upgrade_code: None,
13358 platforms: Vec::new(),
13359 minimum_os_version: None,
13360 switches: InstallerSwitches::default(),
13361 commands: Vec::new(),
13362 package_dependencies: Vec::new(),
13363 require_explicit_upgrade: false,
13364 nested_installer_type: None,
13365 nested_installer_files: Vec::new(),
13366 },
13367 Installer {
13368 architecture: Some("x64".to_owned()),
13369 installer_type: Some("exe".to_owned()),
13370 url: None,
13371 sha256: None,
13372 product_code: None,
13373 locale: Some("en-US".to_owned()),
13374 scope: Some("user".to_owned()),
13375 release_date: None,
13376 package_family_name: None,
13377 upgrade_code: None,
13378 platforms: Vec::new(),
13379 minimum_os_version: None,
13380 switches: InstallerSwitches::default(),
13381 commands: Vec::new(),
13382 package_dependencies: Vec::new(),
13383 require_explicit_upgrade: false,
13384 nested_installer_type: None,
13385 nested_installer_files: Vec::new(),
13386 },
13387 ];
13388
13389 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13390 assert_eq!(selected.scope.as_deref(), Some("machine"));
13391 }
13392
13393 #[test]
13394 fn selects_installer_using_requested_platform() {
13395 let installers = vec![
13396 Installer {
13397 architecture: Some("x64".to_owned()),
13398 installer_type: Some("exe".to_owned()),
13399 url: None,
13400 sha256: None,
13401 product_code: None,
13402 locale: Some("en-US".to_owned()),
13403 scope: Some("user".to_owned()),
13404 release_date: None,
13405 package_family_name: None,
13406 upgrade_code: None,
13407 platforms: vec!["Windows.Universal".to_owned()],
13408 minimum_os_version: None,
13409 switches: InstallerSwitches::default(),
13410 commands: Vec::new(),
13411 package_dependencies: Vec::new(),
13412 require_explicit_upgrade: false,
13413 nested_installer_type: None,
13414 nested_installer_files: Vec::new(),
13415 },
13416 Installer {
13417 architecture: Some("x64".to_owned()),
13418 installer_type: Some("exe".to_owned()),
13419 url: None,
13420 sha256: None,
13421 product_code: None,
13422 locale: Some("en-US".to_owned()),
13423 scope: Some("user".to_owned()),
13424 release_date: None,
13425 package_family_name: None,
13426 upgrade_code: None,
13427 platforms: vec!["Windows.Desktop".to_owned()],
13428 minimum_os_version: None,
13429 switches: InstallerSwitches::default(),
13430 commands: Vec::new(),
13431 package_dependencies: Vec::new(),
13432 require_explicit_upgrade: false,
13433 nested_installer_type: None,
13434 nested_installer_files: Vec::new(),
13435 },
13436 ];
13437 let query = PackageQuery {
13438 installer_type: Some("exe".to_owned()),
13439 platform: Some("Windows.Desktop".to_owned()),
13440 ..PackageQuery::default()
13441 };
13442
13443 let selected = select_installer(&installers, &query).expect("selected installer");
13444 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13445 }
13446
13447 #[test]
13448 fn selects_installer_using_requested_os_version() {
13449 let installers = vec![
13450 Installer {
13451 architecture: Some("x64".to_owned()),
13452 installer_type: Some("exe".to_owned()),
13453 url: None,
13454 sha256: None,
13455 product_code: None,
13456 locale: Some("en-US".to_owned()),
13457 scope: Some("user".to_owned()),
13458 release_date: None,
13459 package_family_name: None,
13460 upgrade_code: None,
13461 platforms: Vec::new(),
13462 minimum_os_version: Some("10.0.22621.0".to_owned()),
13463 switches: InstallerSwitches::default(),
13464 commands: Vec::new(),
13465 package_dependencies: Vec::new(),
13466 require_explicit_upgrade: false,
13467 nested_installer_type: None,
13468 nested_installer_files: Vec::new(),
13469 },
13470 Installer {
13471 architecture: Some("x64".to_owned()),
13472 installer_type: Some("exe".to_owned()),
13473 url: None,
13474 sha256: None,
13475 product_code: None,
13476 locale: Some("en-US".to_owned()),
13477 scope: Some("user".to_owned()),
13478 release_date: None,
13479 package_family_name: None,
13480 upgrade_code: None,
13481 platforms: Vec::new(),
13482 minimum_os_version: Some("10.0.19041.0".to_owned()),
13483 switches: InstallerSwitches::default(),
13484 commands: Vec::new(),
13485 package_dependencies: Vec::new(),
13486 require_explicit_upgrade: false,
13487 nested_installer_type: None,
13488 nested_installer_files: Vec::new(),
13489 },
13490 ];
13491 let query = PackageQuery {
13492 installer_type: Some("exe".to_owned()),
13493 os_version: Some("10.0.19045.0".to_owned()),
13494 ..PackageQuery::default()
13495 };
13496
13497 let selected = select_installer(&installers, &query).expect("selected installer");
13498 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13499 }
13500
13501 #[test]
13502 fn derives_correlation_name_candidates() {
13503 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13504 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13505 assert!(candidates.contains(&"PowerToys".to_owned()));
13506 }
13507
13508 #[test]
13509 fn correlates_installed_package_to_available_match() {
13510 let installed = InstalledPackage {
13511 name: "PowerToys (Preview) x64".to_owned(),
13512 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13513 installed_version: "0.98.1".to_owned(),
13514 publisher: None,
13515 scope: Some("Machine".to_owned()),
13516 installer_category: Some("exe".to_owned()),
13517 install_location: None,
13518 package_family_names: Vec::new(),
13519 product_codes: Vec::new(),
13520 upgrade_codes: Vec::new(),
13521 correlated: None,
13522 winget_package_identifier: None,
13523 winget_source_identifier: None,
13524 installed_version_canonical: false,
13525 correlated_requires_explicit_upgrade: false,
13526 correlated_lacks_compatible_installer: false,
13527 };
13528 let candidates = vec![SearchMatch {
13529 source_name: "winget".to_owned(),
13530 source_kind: SourceKind::PreIndexed,
13531 id: "Microsoft.PowerToys".to_owned(),
13532 name: "PowerToys".to_owned(),
13533 moniker: None,
13534 version: Some("0.98.1".to_owned()),
13535 channel: None,
13536 match_criteria: None,
13537 }];
13538
13539 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13540 assert_eq!(correlated.id, "Microsoft.PowerToys");
13541 }
13542
13543 #[test]
13544 fn list_query_uses_available_lookup_for_tag_filters() {
13545 let query = ListQuery {
13546 tag: Some("terminal".to_owned()),
13547 ..ListQuery::default()
13548 };
13549
13550 assert!(list_query_needs_available_lookup(&query));
13551 }
13552
13553 #[test]
13554 fn list_tag_filter_requires_correlated_match() {
13555 let package = InstalledPackage {
13556 name: "PowerToys (Preview) x64".to_owned(),
13557 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13558 installed_version: "0.98.1".to_owned(),
13559 publisher: None,
13560 scope: Some("Machine".to_owned()),
13561 installer_category: Some("exe".to_owned()),
13562 install_location: None,
13563 package_family_names: Vec::new(),
13564 product_codes: Vec::new(),
13565 upgrade_codes: Vec::new(),
13566 correlated: None,
13567 winget_package_identifier: None,
13568 winget_source_identifier: None,
13569 installed_version_canonical: false,
13570 correlated_requires_explicit_upgrade: false,
13571 correlated_lacks_compatible_installer: false,
13572 };
13573 let query = ListQuery {
13574 tag: Some("powertoys".to_owned()),
13575 ..ListQuery::default()
13576 };
13577
13578 assert!(!list_package_matches(&package, &query));
13579 }
13580
13581 #[test]
13582 fn list_lookup_ignores_display_count() {
13583 let query = ListQuery {
13584 query: Some("git".to_owned()),
13585 count: Some(5),
13586 ..ListQuery::default()
13587 };
13588
13589 let package_query = package_query_from_list_query(&query);
13590 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
13591 }
13592
13593 #[test]
13594 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
13595 let installed = InstalledPackage {
13600 name: "Notepad++ (ARM 64-bit)".to_owned(),
13601 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
13602 installed_version: "8.8.9".to_owned(),
13603 publisher: None,
13604 scope: Some("Machine".to_owned()),
13605 installer_category: Some("exe".to_owned()),
13606 install_location: None,
13607 package_family_names: Vec::new(),
13608 product_codes: Vec::new(),
13609 upgrade_codes: Vec::new(),
13610 correlated: None,
13611 winget_package_identifier: None,
13612 winget_source_identifier: None,
13613 installed_version_canonical: false,
13614 correlated_requires_explicit_upgrade: false,
13615 correlated_lacks_compatible_installer: false,
13616 };
13617 let candidates = vec![
13618 SearchMatch {
13619 source_name: "winget".to_owned(),
13620 source_kind: SourceKind::PreIndexed,
13621 id: "ndd.Notepad--".to_owned(),
13622 name: "Notepad--".to_owned(),
13623 moniker: None,
13624 version: Some("1.0.0".to_owned()),
13625 channel: None,
13626 match_criteria: None,
13627 },
13628 SearchMatch {
13629 source_name: "winget".to_owned(),
13630 source_kind: SourceKind::PreIndexed,
13631 id: "Notepad++.Notepad++".to_owned(),
13632 name: "Notepad++".to_owned(),
13633 moniker: None,
13634 version: Some("8.9.5".to_owned()),
13635 channel: None,
13636 match_criteria: None,
13637 },
13638 ];
13639
13640 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13641 assert_eq!(correlated.id, "Notepad++.Notepad++");
13642 }
13643
13644 #[test]
13645 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
13646 let installed = InstalledPackage {
13651 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
13652 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
13653 installed_version: "40.10.18029".to_owned(),
13654 publisher: None,
13655 scope: Some("Machine".to_owned()),
13656 installer_category: Some("msi".to_owned()),
13657 install_location: None,
13658 package_family_names: Vec::new(),
13659 product_codes: Vec::new(),
13660 upgrade_codes: Vec::new(),
13661 correlated: None,
13662 winget_package_identifier: None,
13663 winget_source_identifier: None,
13664 installed_version_canonical: false,
13665 correlated_requires_explicit_upgrade: false,
13666 correlated_lacks_compatible_installer: false,
13667 };
13668 let candidates = vec![
13669 SearchMatch {
13670 source_name: "winget".to_owned(),
13671 source_kind: SourceKind::PreIndexed,
13672 id: "ZeroBrane.Studio".to_owned(),
13673 name: "Studio".to_owned(),
13674 moniker: None,
13675 version: Some("1.0".to_owned()),
13676 channel: None,
13677 match_criteria: None,
13678 },
13679 SearchMatch {
13680 source_name: "winget".to_owned(),
13681 source_kind: SourceKind::PreIndexed,
13682 id: "BrickLink.Studio".to_owned(),
13683 name: "Studio".to_owned(),
13684 moniker: None,
13685 version: Some("1.0".to_owned()),
13686 channel: None,
13687 match_criteria: None,
13688 },
13689 SearchMatch {
13690 source_name: "winget".to_owned(),
13691 source_kind: SourceKind::PreIndexed,
13692 id: "Microsoft.DotNet.SDK.10".to_owned(),
13693 name: "Microsoft .NET SDK 10.0".to_owned(),
13694 moniker: None,
13695 version: Some("10.0.204".to_owned()),
13696 channel: None,
13697 match_criteria: None,
13698 },
13699 ];
13700
13701 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13702 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
13703 }
13704
13705 #[test]
13706 fn msix_packages_do_not_correlate_via_name() {
13707 let installed = InstalledPackage {
13715 name: "Microsoft Teams".to_owned(),
13716 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13717 installed_version: "25290.205.4069.4894".to_owned(),
13718 publisher: None,
13719 scope: Some("User".to_owned()),
13720 installer_category: Some("msix".to_owned()),
13721 install_location: None,
13722 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13723 product_codes: Vec::new(),
13724 upgrade_codes: Vec::new(),
13725 correlated: None,
13726 winget_package_identifier: None,
13727 winget_source_identifier: None,
13728 installed_version_canonical: false,
13729 correlated_requires_explicit_upgrade: false,
13730 correlated_lacks_compatible_installer: false,
13731 };
13732 let candidates = vec![SearchMatch {
13733 source_name: "winget".to_owned(),
13734 source_kind: SourceKind::PreIndexed,
13735 id: "Microsoft.Teams".to_owned(),
13736 name: "Microsoft Teams".to_owned(),
13737 moniker: None,
13738 version: Some("26106.1906.4665.7308".to_owned()),
13739 channel: None,
13740 match_criteria: None,
13741 }];
13742
13743 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13744 }
13745
13746 #[test]
13747 fn name_fallback_refuses_ambiguous_winners() {
13748 let installed = InstalledPackage {
13756 name: "Git".to_owned(),
13757 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
13758 installed_version: "2.53.0".to_owned(),
13759 publisher: Some("The Git Development Community".to_owned()),
13760 scope: Some("Machine".to_owned()),
13761 installer_category: Some("exe".to_owned()),
13762 install_location: None,
13763 package_family_names: Vec::new(),
13764 product_codes: Vec::new(),
13765 upgrade_codes: Vec::new(),
13766 correlated: None,
13767 winget_package_identifier: None,
13768 winget_source_identifier: None,
13769 installed_version_canonical: false,
13770 correlated_requires_explicit_upgrade: false,
13771 correlated_lacks_compatible_installer: false,
13772 };
13773 let candidates = vec![
13774 SearchMatch {
13775 source_name: "winget".to_owned(),
13776 source_kind: SourceKind::PreIndexed,
13777 id: "Git.Git".to_owned(),
13778 name: "Git".to_owned(),
13779 moniker: None,
13780 version: Some("2.54.0".to_owned()),
13781 channel: None,
13782 match_criteria: None,
13783 },
13784 SearchMatch {
13785 source_name: "winget".to_owned(),
13786 source_kind: SourceKind::PreIndexed,
13787 id: "Microsoft.Git".to_owned(),
13788 name: "Git".to_owned(),
13789 moniker: None,
13790 version: Some("2.53.0.0.7".to_owned()),
13791 channel: None,
13792 match_criteria: None,
13793 },
13794 ];
13795
13796 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13797 }
13798
13799 #[test]
13800 fn msix_with_resource_string_name_does_not_correlate() {
13801 let installed = InstalledPackage {
13805 name: "ms-resource:appDisplayName".to_owned(),
13806 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13807 installed_version: "1.28.239.0".to_owned(),
13808 publisher: None,
13809 scope: Some("User".to_owned()),
13810 installer_category: Some("msix".to_owned()),
13811 install_location: None,
13812 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13813 product_codes: Vec::new(),
13814 upgrade_codes: Vec::new(),
13815 correlated: None,
13816 winget_package_identifier: None,
13817 winget_source_identifier: None,
13818 installed_version_canonical: false,
13819 correlated_requires_explicit_upgrade: false,
13820 correlated_lacks_compatible_installer: false,
13821 };
13822 let candidates = vec![SearchMatch {
13823 source_name: "winget".to_owned(),
13824 source_kind: SourceKind::PreIndexed,
13825 id: "Microsoft.AppInstaller".to_owned(),
13826 name: "App Installer".to_owned(),
13827 moniker: None,
13828 version: Some("1.28.240.0".to_owned()),
13829 channel: None,
13830 match_criteria: None,
13831 }];
13832
13833 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13834 }
13835
13836 #[test]
13837 fn strict_list_correlation_avoids_short_substring_matches() {
13838 let installed = InstalledPackage {
13839 name: "GitHub".to_owned(),
13840 local_id: r"ARP\User\X64\GitHub".to_owned(),
13841 installed_version: "1.0.0".to_owned(),
13842 publisher: None,
13843 scope: Some("User".to_owned()),
13844 installer_category: Some("exe".to_owned()),
13845 install_location: None,
13846 package_family_names: Vec::new(),
13847 product_codes: Vec::new(),
13848 upgrade_codes: Vec::new(),
13849 correlated: None,
13850 winget_package_identifier: None,
13851 winget_source_identifier: None,
13852 installed_version_canonical: false,
13853 correlated_requires_explicit_upgrade: false,
13854 correlated_lacks_compatible_installer: false,
13855 };
13856 let candidates = vec![SearchMatch {
13857 source_name: "winget".to_owned(),
13858 source_kind: SourceKind::PreIndexed,
13859 id: "Git.Git.PreRelease".to_owned(),
13860 name: "Git".to_owned(),
13861 moniker: None,
13862 version: Some("2.54.0".to_owned()),
13863 channel: None,
13864 match_criteria: None,
13865 }];
13866
13867 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
13868 }
13869
13870 #[test]
13871 fn detects_available_upgrade_from_correlated_package() {
13872 let package = InstalledPackage {
13873 name: "AzCopy v10".to_owned(),
13874 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13875 installed_version: "10.32.2".to_owned(),
13876 publisher: None,
13877 scope: Some("Machine".to_owned()),
13878 installer_category: Some("exe".to_owned()),
13879 install_location: None,
13880 package_family_names: Vec::new(),
13881 product_codes: Vec::new(),
13882 upgrade_codes: Vec::new(),
13883 correlated: Some(SearchMatch {
13884 source_name: "winget".to_owned(),
13885 source_kind: SourceKind::PreIndexed,
13886 id: "Microsoft.Azure.AZCopy.10".to_owned(),
13887 name: "AzCopy v10".to_owned(),
13888 moniker: None,
13889 version: Some("10.32.3".to_owned()),
13890 channel: None,
13891 match_criteria: None,
13892 }),
13893 winget_package_identifier: None,
13894 winget_source_identifier: None,
13895 installed_version_canonical: false,
13896 correlated_requires_explicit_upgrade: false,
13897 correlated_lacks_compatible_installer: false,
13898 };
13899
13900 assert!(installed_package_has_upgrade(&package));
13901 }
13902
13903 #[test]
13904 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
13905 let package = InstalledPackage {
13906 name: "Example Tool".to_owned(),
13907 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
13908 installed_version: "Unknown".to_owned(),
13909 publisher: None,
13910 scope: Some("User".to_owned()),
13911 installer_category: Some("exe".to_owned()),
13912 install_location: None,
13913 package_family_names: Vec::new(),
13914 product_codes: Vec::new(),
13915 upgrade_codes: Vec::new(),
13916 correlated: Some(SearchMatch {
13917 source_name: "winget".to_owned(),
13918 source_kind: SourceKind::PreIndexed,
13919 id: "Contoso.ExampleTool".to_owned(),
13920 name: "Example Tool".to_owned(),
13921 moniker: None,
13922 version: Some("2.0.0".to_owned()),
13923 channel: None,
13924 match_criteria: None,
13925 }),
13926 winget_package_identifier: None,
13927 winget_source_identifier: None,
13928 installed_version_canonical: false,
13929 correlated_requires_explicit_upgrade: false,
13930 correlated_lacks_compatible_installer: false,
13931 };
13932 let query = ListQuery {
13933 upgrade_only: true,
13934 include_unknown: true,
13935 ..Default::default()
13936 };
13937
13938 assert!(installed_package_matches_upgrade_filter(&package, &query));
13939 assert_eq!(
13940 list_match_from_installed(package, &[]).available_version.as_deref(),
13941 Some("2.0.0")
13942 );
13943 }
13944
13945 #[test]
13946 fn create_repair_list_query_includes_installed_selectors() {
13947 let request = RepairRequest {
13948 query: PackageQuery {
13949 query: Some("powertoys".to_owned()),
13950 id: Some("Microsoft.PowerToys".to_owned()),
13951 name: Some("PowerToys".to_owned()),
13952 moniker: Some("powertoys".to_owned()),
13953 source: Some("winget".to_owned()),
13954 exact: true,
13955 version: Some("0.98.1".to_owned()),
13956 install_scope: Some("Machine".to_owned()),
13957 ..PackageQuery::default()
13958 },
13959 manifest_path: None,
13960 product_code: Some("{GUID}".to_owned()),
13961 mode: InstallerMode::Silent,
13962 log_path: Some(PathBuf::from("repair.log")),
13963 accept_package_agreements: true,
13964 force: true,
13965 ignore_security_hash: true,
13966 };
13967
13968 let query = Repository::create_repair_list_query(&request);
13969 assert_eq!(query.query.as_deref(), Some("powertoys"));
13970 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
13971 assert_eq!(query.name.as_deref(), Some("PowerToys"));
13972 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
13973 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
13974 assert_eq!(query.version.as_deref(), Some("0.98.1"));
13975 assert_eq!(query.source.as_deref(), Some("winget"));
13976 assert!(query.exact);
13977 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
13978 assert_eq!(query.count, Some(100));
13979 }
13980
13981 #[test]
13982 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
13983 let request = RepairRequest {
13984 query: PackageQuery {
13985 name: Some("PowerToys".to_owned()),
13986 source: Some("winget".to_owned()),
13987 locale: Some("en-US".to_owned()),
13988 installer_architecture: Some("x64".to_owned()),
13989 install_scope: Some("Machine".to_owned()),
13990 ..PackageQuery::default()
13991 },
13992 manifest_path: None,
13993 product_code: None,
13994 mode: InstallerMode::SilentWithProgress,
13995 log_path: Some(PathBuf::from("repair.log")),
13996 accept_package_agreements: true,
13997 force: false,
13998 ignore_security_hash: true,
13999 };
14000 let installed = ListMatch {
14001 name: "PowerToys".to_owned(),
14002 id: "Microsoft.PowerToys".to_owned(),
14003 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
14004 installed_version: "0.98.1".to_owned(),
14005 available_version: Some("0.98.2".to_owned()),
14006 source_name: Some("winget".to_owned()),
14007 publisher: None,
14008 scope: Some("Machine".to_owned()),
14009 installer_category: Some("exe".to_owned()),
14010 install_location: None,
14011 package_family_names: Vec::new(),
14012 product_codes: Vec::new(),
14013 upgrade_codes: Vec::new(),
14014 };
14015
14016 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
14017 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
14018 assert!(install_request.query.query.is_none());
14019 assert!(install_request.query.name.is_none());
14020 assert!(install_request.query.moniker.is_none());
14021 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
14022 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
14023 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
14024 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
14025 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
14026 assert!(install_request.query.exact);
14027 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
14028 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
14029 assert!(install_request.accept_package_agreements);
14030 assert!(install_request.force);
14031 assert!(install_request.ignore_security_hash);
14032 }
14033
14034 #[test]
14035 fn source_scoped_pin_operations_round_trip() {
14036 let app_root = temp_app_root("pins");
14037 let result = (|| -> Result<()> {
14038 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14039 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
14040 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
14041 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
14042
14043 assert_eq!(repository.list_pins(None)?.len(), 3);
14044 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
14045 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
14046
14047 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
14048 assert_eq!(repository.list_pins(None)?.len(), 2);
14049 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
14050
14051 repository.reset_pins(Some("test"))?;
14052 let remaining = repository.list_pins(None)?;
14053 assert_eq!(remaining.len(), 1);
14054 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
14055
14056 repository.reset_pins(None)?;
14057 assert!(repository.list_pins(None)?.is_empty());
14058 Ok(())
14059 })();
14060
14061 let _ = fs::remove_dir_all(&app_root);
14062 result.expect("pin round trip");
14063 }
14064
14065 #[test]
14066 fn pin_operations_work_with_packaged_pin_schema() {
14067 let app_root = temp_app_root("pins_packaged_schema");
14068 let result = (|| -> Result<()> {
14069 let db_path = pins_db_path(&app_root);
14070 if let Some(parent) = db_path.parent() {
14071 fs::create_dir_all(parent)?;
14072 }
14073
14074 let conn = Repository::open_sqlite_connection(db_path.clone())?;
14075 execute_batch_sql(
14076 &conn,
14077 "CREATE TABLE pin (
14078 package_id TEXT NOT NULL,
14079 source_id TEXT NOT NULL,
14080 type INTEGER NOT NULL,
14081 version TEXT NOT NULL,
14082 PRIMARY KEY (package_id, source_id)
14083 )",
14084 )?;
14085 conn.cacheflush().context("failed to flush pin database")?;
14086 drop(conn);
14087
14088 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14089 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14090
14091 let pins = repository.list_pins(None)?;
14092 assert_eq!(pins.len(), 1);
14093 assert_eq!(pins[0].package_id, "Contoso.Tool");
14094 assert_eq!(pins[0].version, "1.2.*");
14095 assert_eq!(pins[0].source_id, "winget");
14096 assert_eq!(pins[0].pin_type, PinType::Gating);
14097
14098 let conn = Repository::open_sqlite_connection(db_path)?;
14099 let stored_pin = query_optional_value(
14100 &conn,
14101 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14102 vec![
14103 SqlValue::Text("Contoso.Tool".to_owned()),
14104 SqlValue::Text("winget".to_owned()),
14105 ],
14106 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14107 )?
14108 .ok_or_else(|| anyhow!("expected stored pin"))?;
14109 assert_eq!(stored_pin.0, 3);
14110 assert_eq!(stored_pin.1, "1.2.*");
14111
14112 Ok(())
14113 })();
14114
14115 let _ = fs::remove_dir_all(&app_root);
14116 result.expect("packaged pin schema round trip");
14117 }
14118
14119 #[test]
14120 fn version_pin_patterns_match_exact_and_prefix_values() {
14121 assert!(version_matches_pin_pattern("1.2.3", "*"));
14122 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14123 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14124 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14125 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14126 }
14127
14128 #[test]
14129 fn source_specific_pins_override_source_agnostic_pins() {
14130 let item = ListMatch {
14131 name: "Contoso Tool".to_owned(),
14132 id: "Contoso.Tool".to_owned(),
14133 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14134 installed_version: "1.0.0".to_owned(),
14135 available_version: Some("2.0.0".to_owned()),
14136 source_name: Some("winget".to_owned()),
14137 publisher: None,
14138 scope: None,
14139 installer_category: None,
14140 install_location: None,
14141 package_family_names: Vec::new(),
14142 product_codes: Vec::new(),
14143 upgrade_codes: Vec::new(),
14144 };
14145 let pins = vec![
14146 PinRecord {
14147 package_id: "Contoso.Tool".to_owned(),
14148 version: "*".to_owned(),
14149 source_id: String::new(),
14150 pin_type: PinType::Blocking,
14151 },
14152 PinRecord {
14153 package_id: "Contoso.Tool".to_owned(),
14154 version: "2.0.*".to_owned(),
14155 source_id: "winget".to_owned(),
14156 pin_type: PinType::Pinning,
14157 },
14158 ];
14159
14160 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14161 }
14162
14163 #[test]
14164 fn blocking_pin_blocks_upgrade() {
14165 let item = ListMatch {
14166 name: "Contoso Tool".to_owned(),
14167 id: "Contoso.Tool".to_owned(),
14168 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14169 installed_version: "1.0.0".to_owned(),
14170 available_version: Some("2.0.0".to_owned()),
14171 source_name: Some("winget".to_owned()),
14172 publisher: None,
14173 scope: None,
14174 installer_category: None,
14175 install_location: None,
14176 package_family_names: Vec::new(),
14177 product_codes: Vec::new(),
14178 upgrade_codes: Vec::new(),
14179 };
14180 let pins = vec![PinRecord {
14181 package_id: "Contoso.Tool".to_owned(),
14182 version: "*".to_owned(),
14183 source_id: "winget".to_owned(),
14184 pin_type: PinType::Blocking,
14185 }];
14186
14187 assert!(is_upgrade_blocked_by_pin(&item, &pins));
14188 }
14189
14190 #[cfg(windows)]
14191 #[test]
14192 fn parses_msix_package_full_name_into_version_and_family() {
14193 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14194 .expect("package metadata");
14195
14196 assert_eq!(parsed.version, "0.98.1.0");
14197 assert_eq!(parsed.resource_id, "");
14198 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14199 assert!(!is_msix_split_resource_package(&parsed.resource_id));
14200 }
14201
14202 #[cfg(windows)]
14203 #[test]
14204 fn classifies_msix_split_resource_package() {
14205 let parsed = parse_msix_package_full_name(
14206 "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14207 )
14208 .expect("package metadata");
14209
14210 assert_eq!(parsed.version, "1.97.17002.0");
14211 assert_eq!(parsed.resource_id, "split.scale-125");
14212 assert_eq!(
14213 parsed.family_name,
14214 "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14215 );
14216 assert!(is_msix_split_resource_package(&parsed.resource_id));
14217 }
14218
14219 #[cfg(windows)]
14220 #[test]
14221 fn recognizes_windows_system_paths() {
14222 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14223 assert!(!is_windows_system_path(
14224 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14225 ));
14226 }
14227
14228 #[test]
14229 fn search_ranking_prefers_exact_name_over_tag_match() {
14230 let query = PackageQuery {
14231 query: Some("PowerToys".to_owned()),
14232 ..Default::default()
14233 };
14234 let exact_name = SearchMatch {
14235 source_name: "winget".to_owned(),
14236 source_kind: SourceKind::PreIndexed,
14237 id: "Microsoft.PowerToys".to_owned(),
14238 name: "PowerToys".to_owned(),
14239 moniker: Some("powertoys".to_owned()),
14240 version: Some("0.98.1".to_owned()),
14241 channel: None,
14242 match_criteria: Some("Moniker: powertoys".to_owned()),
14243 };
14244 let tag_match = SearchMatch {
14245 source_name: "winget".to_owned(),
14246 source_kind: SourceKind::PreIndexed,
14247 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14248 name: "QR Codes for Command Palette".to_owned(),
14249 moniker: None,
14250 version: Some("0.4.0.0".to_owned()),
14251 channel: None,
14252 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14253 };
14254
14255 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14256 }
14257
14258 #[test]
14259 fn select_best_text_match_prefers_exact_tag_value() {
14260 let values = vec![
14261 "microsoft-powertoys".to_owned(),
14262 "powertoys-run".to_owned(),
14263 "powertoys".to_owned(),
14264 ];
14265
14266 assert_eq!(
14267 select_best_text_match(values, "PowerToys", false).as_deref(),
14268 Some("powertoys")
14269 );
14270 }
14271
14272 #[test]
14273 fn search_match_criteria_is_blank_for_name_match() {
14274 let query = PackageQuery {
14275 query: Some("PowerToys".to_owned()),
14276 ..Default::default()
14277 };
14278
14279 let result = infer_match_criteria(
14280 "Microsoft.PowerToys",
14281 "PowerToys",
14282 Some("powertoys"),
14283 &query,
14284 SearchSemantics::Many,
14285 |_| Ok(Some("powertoys".to_owned())),
14286 |_| Ok(None),
14287 )
14288 .expect("match criteria");
14289
14290 assert_eq!(result, None);
14291 }
14292
14293 #[test]
14294 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14295 let query = PackageQuery {
14296 query: Some("PowerToys".to_owned()),
14297 ..Default::default()
14298 };
14299 let unknown_version = SearchMatch {
14300 source_name: "msstore".to_owned(),
14301 source_kind: SourceKind::Rest,
14302 id: "XP89DCGQ3K6VLD".to_owned(),
14303 name: "Microsoft PowerToys".to_owned(),
14304 moniker: None,
14305 version: Some("Unknown".to_owned()),
14306 channel: None,
14307 match_criteria: None,
14308 };
14309 let tag_match = SearchMatch {
14310 source_name: "winget".to_owned(),
14311 source_kind: SourceKind::PreIndexed,
14312 id: "Riri.QRCodeforCmdPal".to_owned(),
14313 name: "QR Code for CmdPal".to_owned(),
14314 moniker: None,
14315 version: Some("0.0.1.0".to_owned()),
14316 channel: None,
14317 match_criteria: Some("Tag: powertoys".to_owned()),
14318 };
14319
14320 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14321 }
14322
14323 #[test]
14324 fn search_source_fetch_results_ignore_small_display_count() {
14325 let query = PackageQuery {
14326 count: Some(5),
14327 ..Default::default()
14328 };
14329
14330 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14331 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14332 }
14333
14334 #[test]
14335 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14336 let query = PackageQuery {
14337 query: Some("PowerToys".to_owned()),
14338 ..Default::default()
14339 };
14340 let prefix_name = SearchMatch {
14341 source_name: "winget".to_owned(),
14342 source_kind: SourceKind::PreIndexed,
14343 id: "advaith.CurrencyConverterPowerToys".to_owned(),
14344 name: "PowerToys-Run-Currency-Converter".to_owned(),
14345 moniker: None,
14346 version: Some("1.5.4".to_owned()),
14347 channel: None,
14348 match_criteria: None,
14349 };
14350 let exact_tag = SearchMatch {
14351 source_name: "winget".to_owned(),
14352 source_kind: SourceKind::PreIndexed,
14353 id: "Riri.QRCodeforCmdPal".to_owned(),
14354 name: "QR Code for CmdPal".to_owned(),
14355 moniker: None,
14356 version: Some("0.0.1.0".to_owned()),
14357 channel: None,
14358 match_criteria: Some("Tag: powertoys".to_owned()),
14359 };
14360
14361 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14362 }
14363
14364 #[test]
14365 fn list_sort_prefers_main_package_then_sparse_app() {
14366 let main = InstalledPackage {
14367 name: "PowerToys (Preview) x64".to_owned(),
14368 local_id: r"ARP\User\X64\PowerToys".to_owned(),
14369 installed_version: "0.98.1".to_owned(),
14370 publisher: None,
14371 scope: Some("User".to_owned()),
14372 installer_category: Some("exe".to_owned()),
14373 install_location: None,
14374 package_family_names: Vec::new(),
14375 product_codes: Vec::new(),
14376 upgrade_codes: Vec::new(),
14377 correlated: None,
14378 winget_package_identifier: None,
14379 winget_source_identifier: None,
14380 installed_version_canonical: false,
14381 correlated_requires_explicit_upgrade: false,
14382 correlated_lacks_compatible_installer: false,
14383 };
14384 let sparse = InstalledPackage {
14385 name: "PowerToys.SparseApp".to_owned(),
14386 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14387 installed_version: "0.98.1.0".to_owned(),
14388 publisher: None,
14389 scope: Some("User".to_owned()),
14390 installer_category: Some("msix".to_owned()),
14391 install_location: None,
14392 package_family_names: Vec::new(),
14393 product_codes: Vec::new(),
14394 upgrade_codes: Vec::new(),
14395 correlated: None,
14396 winget_package_identifier: None,
14397 winget_source_identifier: None,
14398 installed_version_canonical: false,
14399 correlated_requires_explicit_upgrade: false,
14400 correlated_lacks_compatible_installer: false,
14401 };
14402 let extension = InstalledPackage {
14403 name: "PowerToys FileLocksmith Context Menu".to_owned(),
14404 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14405 installed_version: "0.98.1.0".to_owned(),
14406 publisher: None,
14407 scope: Some("User".to_owned()),
14408 installer_category: Some("msix".to_owned()),
14409 install_location: None,
14410 package_family_names: Vec::new(),
14411 product_codes: Vec::new(),
14412 upgrade_codes: Vec::new(),
14413 correlated: None,
14414 winget_package_identifier: None,
14415 winget_source_identifier: None,
14416 installed_version_canonical: false,
14417 correlated_requires_explicit_upgrade: false,
14418 correlated_lacks_compatible_installer: false,
14419 };
14420
14421 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14422 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14423 }
14424
14425 #[test]
14426 #[cfg(windows)]
14427 fn arp_subkey_name_extracts_registry_subkey() {
14428 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14429 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14430 }
14431
14432 #[test]
14433 #[cfg(windows)]
14434 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14435 let installed = ListMatch {
14436 name: "ShareX".to_owned(),
14437 id: "ShareX.ShareX".to_owned(),
14438 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14439 installed_version: "19.0.2".to_owned(),
14440 available_version: None,
14441 source_name: Some("winget".to_owned()),
14442 publisher: Some("ShareX Team".to_owned()),
14443 scope: Some("Machine".to_owned()),
14444 installer_category: Some("exe".to_owned()),
14445 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14446 package_family_names: Vec::new(),
14447 product_codes: Vec::new(),
14448 upgrade_codes: Vec::new(),
14449 };
14450
14451 assert!(registry_entry_matches_installed_package(
14452 "ShareX", "ShareX", None, &installed
14453 ));
14454 assert!(!registry_entry_matches_installed_package(
14455 "ShareX.ShareX",
14456 "ShareX.ShareX",
14457 None,
14458 &installed
14459 ));
14460 }
14461
14462 #[test]
14463 #[cfg(windows)]
14464 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14465 assert_eq!(
14466 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14467 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14468 );
14469 assert_eq!(
14470 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14471 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14472 );
14473 assert_eq!(
14474 r#""C:\Program Files\ShareX\unins000.exe""#,
14475 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14476 );
14477 assert_eq!(
14478 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14479 build_uninstall_command(
14480 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14481 true,
14482 false,
14483 )
14484 );
14485 }
14486
14487 #[test]
14488 #[cfg(windows)]
14489 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14490 let installed = ListMatch {
14491 name: "lazygit".to_owned(),
14492 id: "JesseDuffield.lazygit".to_owned(),
14493 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14494 installed_version: "0.61.1".to_owned(),
14495 available_version: None,
14496 source_name: Some("winget".to_owned()),
14497 publisher: Some("Jesse Duffield".to_owned()),
14498 scope: Some("User".to_owned()),
14499 installer_category: Some("exe".to_owned()),
14500 install_location: Some(
14501 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14502 ),
14503 package_family_names: Vec::new(),
14504 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14505 upgrade_codes: Vec::new(),
14506 };
14507 let mut request = UninstallRequest::new(PackageQuery {
14508 id: Some("JesseDuffield.lazygit".to_owned()),
14509 install_scope: Some("user".to_owned()),
14510 ..PackageQuery::default()
14511 });
14512 request.mode = InstallerMode::Silent;
14513
14514 assert_eq!(
14515 Some(vec![
14516 "uninstall".to_owned(),
14517 "--id".to_owned(),
14518 "JesseDuffield.lazygit".to_owned(),
14519 "--exact".to_owned(),
14520 "--disable-interactivity".to_owned(),
14521 "--source".to_owned(),
14522 "winget".to_owned(),
14523 "--scope".to_owned(),
14524 "user".to_owned(),
14525 "--silent".to_owned(),
14526 ]),
14527 build_winget_uninstall_arguments(&installed, &request)
14528 );
14529
14530 assert_eq!(
14531 Some(vec![
14532 "uninstall".to_owned(),
14533 "--id".to_owned(),
14534 "JesseDuffield.lazygit".to_owned(),
14535 "--exact".to_owned(),
14536 "--disable-interactivity".to_owned(),
14537 "--source".to_owned(),
14538 "winget".to_owned(),
14539 "--silent".to_owned(),
14540 ]),
14541 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
14542 );
14543 }
14544
14545 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
14546 PackageVersionDataEntry {
14547 version: version.to_owned(),
14548 manifest_relative_path: format!("manifests/x/{version}"),
14549 manifest_hash: "00".repeat(32),
14550 arp_min_version: arp_min.map(str::to_owned),
14551 arp_max_version: arp_max.map(str::to_owned),
14552 }
14553 }
14554
14555 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
14556 V1VersionRow {
14557 version: version.to_owned(),
14558 channel: channel.to_owned(),
14559 pathpart_id: 0,
14560 manifest_hash: None,
14561 }
14562 }
14563
14564 #[derive(Clone)]
14565 struct TestHttpResponse {
14566 status: u16,
14567 body: Vec<u8>,
14568 source_version: Option<String>,
14569 etag: Option<String>,
14570 last_modified: Option<String>,
14571 }
14572
14573 struct TestHttpServer {
14574 addr: SocketAddr,
14575 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14576 counts: Arc<Mutex<HashMap<String, usize>>>,
14577 running: Arc<AtomicBool>,
14578 handle: Option<JoinHandle<()>>,
14579 }
14580
14581 impl TestHttpServer {
14582 fn start() -> Self {
14583 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
14584 let addr = listener.local_addr().expect("test HTTP server address");
14585 let routes = Arc::new(Mutex::new(HashMap::new()));
14586 let counts = Arc::new(Mutex::new(HashMap::new()));
14587 let running = Arc::new(AtomicBool::new(true));
14588 let thread_routes = Arc::clone(&routes);
14589 let thread_counts = Arc::clone(&counts);
14590 let thread_running = Arc::clone(&running);
14591 let handle = thread::spawn(move || {
14592 while thread_running.load(AtomicOrdering::SeqCst) {
14593 let Ok((stream, _)) = listener.accept() else {
14594 continue;
14595 };
14596 handle_test_http_request(stream, &thread_routes, &thread_counts);
14597 }
14598 });
14599
14600 Self {
14601 addr,
14602 routes,
14603 counts,
14604 running,
14605 handle: Some(handle),
14606 }
14607 }
14608
14609 fn url(&self) -> String {
14610 format!("http://{}", self.addr)
14611 }
14612
14613 fn set_bytes(&self, path: &str, body: Vec<u8>) {
14614 self.set_response(
14615 path,
14616 TestHttpResponse {
14617 status: 200,
14618 body,
14619 source_version: None,
14620 etag: None,
14621 last_modified: None,
14622 },
14623 );
14624 }
14625
14626 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
14627 self.set_source_package_with_validators(body, source_version, None, None);
14628 }
14629
14630 fn set_source_package_with_validators(
14631 &self,
14632 body: Vec<u8>,
14633 source_version: &str,
14634 etag: Option<&str>,
14635 last_modified: Option<&str>,
14636 ) {
14637 self.set_response(
14638 "/source.msix",
14639 TestHttpResponse {
14640 status: 200,
14641 body,
14642 source_version: Some(source_version.to_owned()),
14643 etag: etag.map(str::to_owned),
14644 last_modified: last_modified.map(str::to_owned),
14645 },
14646 );
14647 }
14648
14649 fn set_status(&self, path: &str, status: u16) {
14650 self.set_response(
14651 path,
14652 TestHttpResponse {
14653 status,
14654 body: Vec::new(),
14655 source_version: None,
14656 etag: None,
14657 last_modified: None,
14658 },
14659 );
14660 }
14661
14662 fn set_response(&self, path: &str, response: TestHttpResponse) {
14663 self.routes
14664 .lock()
14665 .expect("test HTTP routes")
14666 .insert(path.to_owned(), response);
14667 }
14668
14669 fn request_count(&self, path: &str) -> usize {
14670 self.counts
14671 .lock()
14672 .expect("test HTTP counts")
14673 .get(path)
14674 .copied()
14675 .unwrap_or_default()
14676 }
14677 }
14678
14679 impl Drop for TestHttpServer {
14680 fn drop(&mut self) {
14681 self.running.store(false, AtomicOrdering::SeqCst);
14682 let _ = TcpStream::connect(self.addr);
14683 if let Some(handle) = self.handle.take() {
14684 let _ = handle.join();
14685 }
14686 }
14687 }
14688
14689 fn handle_test_http_request(
14690 mut stream: TcpStream,
14691 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14692 counts: &Arc<Mutex<HashMap<String, usize>>>,
14693 ) {
14694 let mut buffer = [0u8; 2048];
14695 let read = stream.read(&mut buffer).unwrap_or(0);
14696 let request = String::from_utf8_lossy(&buffer[..read]);
14697 let path = request
14698 .lines()
14699 .next()
14700 .and_then(|line| line.split_whitespace().nth(1))
14701 .unwrap_or("/");
14702 *counts
14703 .lock()
14704 .expect("test HTTP counts")
14705 .entry(path.to_owned())
14706 .or_default() += 1;
14707 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
14708 let response = response.unwrap_or(TestHttpResponse {
14709 status: 404,
14710 body: Vec::new(),
14711 source_version: None,
14712 etag: None,
14713 last_modified: None,
14714 });
14715 let not_modified = response.status == 200
14716 && (response.etag.as_deref().is_some_and(|etag| {
14717 request_header_value(&request, "If-None-Match")
14718 .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
14719 }) || response.last_modified.as_deref().is_some_and(|last_modified| {
14720 request_header_value(&request, "If-Modified-Since")
14721 .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
14722 }));
14723 let status = if not_modified { 304 } else { response.status };
14724 let body = if not_modified {
14725 &[][..]
14726 } else {
14727 response.body.as_slice()
14728 };
14729 let reason = match status {
14730 200 => "OK",
14731 304 => "Not Modified",
14732 _ => "Not Found",
14733 };
14734 let mut headers = format!(
14735 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
14736 status,
14737 reason,
14738 body.len()
14739 );
14740 if let Some(source_version) = response.source_version {
14741 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
14742 }
14743 if let Some(etag) = response.etag {
14744 headers.push_str(&format!("ETag: {etag}\r\n"));
14745 }
14746 if let Some(last_modified) = response.last_modified {
14747 headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
14748 }
14749 headers.push_str("\r\n");
14750 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
14751 stream.write_all(body).expect("write test HTTP body");
14752 }
14753
14754 fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
14755 request.lines().find_map(|line| {
14756 let (header_name, value) = line.split_once(':')?;
14757 header_name.eq_ignore_ascii_case(name).then(|| value.trim())
14758 })
14759 }
14760
14761 fn test_source_record(source_url: String) -> SourceRecord {
14762 SourceRecord {
14763 name: "test-winget".to_owned(),
14764 kind: SourceKind::PreIndexed,
14765 arg: source_url,
14766 identifier: "Test.Source".to_owned(),
14767 trust_level: "Trusted".to_owned(),
14768 explicit: false,
14769 priority: 0,
14770 last_update: Some(Utc::now()),
14771 source_version: None,
14772 etag: None,
14773 last_modified: None,
14774 }
14775 }
14776
14777 fn open_test_preindexed_repository(
14778 app_root: &Path,
14779 source: &SourceRecord,
14780 interval: Option<Duration>,
14781 ) -> Repository {
14782 fs::create_dir_all(app_root).expect("create app root");
14783 save_store(
14784 app_root,
14785 &SourceStore {
14786 sources: vec![source.clone()],
14787 },
14788 )
14789 .expect("save source store");
14790 Repository::open_with_options(
14791 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
14792 )
14793 .expect("open repository")
14794 }
14795
14796 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
14797 let index_path = preindexed_index_path(app_root, source);
14798 if let Some(parent) = index_path.parent() {
14799 fs::create_dir_all(parent).expect("create source state directory");
14800 }
14801 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
14802 }
14803
14804 #[test]
14805 fn replace_file_failure_keeps_existing_target() {
14806 let root = temp_app_root("replace_file_failure");
14807 fs::create_dir_all(&root).expect("create replace root");
14808 let target = root.join("index.db");
14809 let missing_source = root.join("missing.tmp");
14810 fs::write(&target, b"old-index").expect("write existing target");
14811
14812 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
14813
14814 assert!(format!("{error:#}").contains("failed to replace"));
14815 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
14816 let _ = fs::remove_dir_all(root);
14817 }
14818
14819 #[cfg(windows)]
14820 #[test]
14821 fn replace_file_succeeds_while_target_is_open() {
14822 use std::os::windows::fs::OpenOptionsExt;
14823 const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
14827
14828 let root = temp_app_root("replace_file_open_target");
14829 fs::create_dir_all(&root).expect("create replace root");
14830 let target = root.join("index.db");
14831 let source = root.join("index.new.tmp");
14832 fs::write(&target, b"old-index").expect("write existing target");
14833 fs::write(&source, b"new-index").expect("write replacement");
14834
14835 let held = OpenOptions::new()
14836 .read(true)
14837 .share_mode(SHARE_ALL)
14838 .open(&target)
14839 .expect("open target with share-delete");
14840
14841 replace_file(&source, &target).expect("stage-aside replace should succeed");
14842
14843 assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
14844 drop(held);
14845 let _ = fs::remove_dir_all(root);
14846 }
14847
14848 fn make_source_package(versions: &[&str]) -> Vec<u8> {
14849 let cursor = Cursor::new(Vec::new());
14850 let mut archive = zip::ZipWriter::new(cursor);
14851 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
14852 archive
14853 .start_file("Public/index.db", options)
14854 .expect("start index entry");
14855 archive
14856 .write_all(&make_v1_index_bytes(versions))
14857 .expect("write index entry");
14858 archive.finish().expect("finish source package").into_inner()
14859 }
14860
14861 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
14862 let root = temp_app_root("preindexed_index_bytes");
14863 fs::create_dir_all(&root).expect("create index temp root");
14864 let db_path = root.join("index.db");
14865 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
14866 let mut sql = String::from(
14867 "CREATE TABLE ids (id TEXT);
14868 CREATE TABLE names (name TEXT);
14869 CREATE TABLE monikers (moniker TEXT);
14870 CREATE TABLE versions (version TEXT);
14871 CREATE TABLE channels (channel TEXT);
14872 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
14873 CREATE TABLE manifest (
14874 id INT64,
14875 name INT64,
14876 moniker INT64,
14877 version INT64,
14878 channel INT64,
14879 pathpart INT64
14880 );
14881 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
14882 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
14883 INSERT INTO channels(rowid, channel) VALUES (1, '');
14884 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
14885 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
14886 );
14887 for (index, version) in versions.iter().enumerate() {
14888 let rowid = index + 1;
14889 sql.push_str(&format!(
14890 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
14891 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
14892 ));
14893 }
14894 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
14895 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
14896 .expect("checkpoint preindexed V1 database");
14897 connection.cacheflush().expect("flush preindexed V1 database");
14898 drop(connection);
14899 let bytes = fs::read(&db_path).expect("read index bytes");
14900 let _ = fs::remove_dir_all(root);
14901 bytes
14902 }
14903
14904 fn test_manifest_yaml(version: &str) -> Vec<u8> {
14905 format!(
14906 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
14907 )
14908 .into_bytes()
14909 }
14910
14911 fn show_test_package(version: Option<&str>) -> PackageQuery {
14912 PackageQuery {
14913 id: Some("Test.Package".to_owned()),
14914 source: Some("test-winget".to_owned()),
14915 exact: true,
14916 version: version.map(str::to_owned),
14917 ..PackageQuery::default()
14918 }
14919 }
14920
14921 #[test]
14922 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
14923 let app_root = temp_app_root("preindexed_explicit_refresh");
14924 let server = TestHttpServer::start();
14925 server.set_status("/source2.msix", 404);
14926 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
14927 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14928 let source = test_source_record(server.url());
14929 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14930 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14931
14932 let result = repository
14933 .show(&show_test_package(Some("2.0.0")))
14934 .expect("show after refresh");
14935
14936 assert_eq!(result.manifest.version, "2.0.0");
14937 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
14938 let _ = fs::remove_dir_all(app_root);
14939 }
14940
14941 #[test]
14942 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
14943 let app_root = temp_app_root("preindexed_explicit_still_missing");
14944 let server = TestHttpServer::start();
14945 server.set_status("/source2.msix", 404);
14946 server.set_source_package(make_source_package(&["1.0.0"]), "same");
14947 let source = test_source_record(server.url());
14948 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14949 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14950
14951 let error = repository
14952 .show(&show_test_package(Some("2.0.0")))
14953 .expect_err("missing explicit version must fail");
14954
14955 assert!(format!("{error:#}").contains("2.0.0"));
14956 let _ = fs::remove_dir_all(app_root);
14957 }
14958
14959 #[test]
14960 fn preindexed_stale_index_refreshes_for_latest_requests() {
14961 let app_root = temp_app_root("preindexed_ttl_refresh");
14962 let server = TestHttpServer::start();
14963 server.set_status("/source2.msix", 404);
14964 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14965 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14966 let mut source = test_source_record(server.url());
14967 source.last_update = Some(Utc::now() - Duration::days(2));
14968 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14969 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14970
14971 let result = repository
14972 .show(&show_test_package(None))
14973 .expect("show latest after TTL refresh");
14974
14975 assert_eq!(result.manifest.version, "2.0.0");
14976 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
14977 let _ = fs::remove_dir_all(app_root);
14978 }
14979
14980 #[test]
14981 fn preindexed_stale_index_not_modified_keeps_local_index() {
14982 let app_root = temp_app_root("preindexed_ttl_not_modified");
14983 let server = TestHttpServer::start();
14984 server.set_status("/source2.msix", 404);
14985 server.set_source_package_with_validators(
14986 make_source_package(&["1.0.0", "2.0.0"]),
14987 "fresh",
14988 Some("\"same-index\""),
14989 Some("Wed, 03 Jun 2026 12:00:00 GMT"),
14990 );
14991 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14992 let mut source = test_source_record(server.url());
14993 source.last_update = Some(Utc::now() - Duration::days(2));
14994 source.etag = Some("\"same-index\"".to_owned());
14995 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
14996 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14997 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14998
14999 let result = repository
15000 .show(&show_test_package(None))
15001 .expect("show latest from not-modified stale index");
15002
15003 let refreshed_source = &repository.list_sources()[0];
15004 assert_eq!(result.manifest.version, "1.0.0");
15005 assert_eq!(server.request_count("/source.msix"), 1);
15006 assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
15007 assert_eq!(
15008 refreshed_source.last_modified.as_deref(),
15009 Some("Wed, 03 Jun 2026 12:00:00 GMT")
15010 );
15011 let _ = fs::remove_dir_all(app_root);
15012 }
15013
15014 #[test]
15015 fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
15016 let app_root = temp_app_root("preindexed_fresh_mtime");
15017 let server = TestHttpServer::start();
15018 server.set_status("/source2.msix", 404);
15019 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15020 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15021 let mut source = test_source_record(server.url());
15022 source.last_update = Some(Utc::now() - Duration::days(2));
15023 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15024 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
15025
15026 let result = repository
15027 .show(&show_test_package(None))
15028 .expect("show latest from fresh index mtime");
15029
15030 assert_eq!(result.manifest.version, "1.0.0");
15031 assert_eq!(server.request_count("/source.msix"), 0);
15032 let _ = fs::remove_dir_all(app_root);
15033 }
15034
15035 #[test]
15036 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
15037 let app_root = temp_app_root("preindexed_ttl_fallback");
15038 let server = TestHttpServer::start();
15039 server.set_status("/source2.msix", 404);
15040 server.set_status("/source.msix", 404);
15041 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15042 let mut source = test_source_record(server.url());
15043 source.last_update = Some(Utc::now() - Duration::days(2));
15044 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15045 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15046
15047 let result = repository
15048 .show(&show_test_package(None))
15049 .expect("show latest from stale cache after refresh failure");
15050
15051 assert_eq!(result.manifest.version, "1.0.0");
15052 let _ = fs::remove_dir_all(app_root);
15053 }
15054
15055 #[test]
15056 fn explicit_source_search_failure_is_not_reported_as_no_match() {
15057 let app_root = temp_app_root("preindexed_source_search_failure");
15058 let server = TestHttpServer::start();
15059 server.set_status("/source2.msix", 404);
15060 server.set_status("/source.msix", 404);
15061 let source = test_source_record(server.url());
15062 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15063
15064 let error = repository
15065 .show(&show_test_package(None))
15066 .expect_err("source failure must surface");
15067 let message = format!("{error:#}");
15068
15069 assert!(message.contains("failed to search source 'test-winget'"));
15070 assert!(message.contains("source.msix"));
15071 assert!(!message.contains("no package matched the supplied query"));
15072 let _ = fs::remove_dir_all(app_root);
15073 }
15074
15075 #[test]
15076 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15077 let app_root = temp_app_root("preindexed_no_double_refresh");
15078 let server = TestHttpServer::start();
15079 server.set_status("/source2.msix", 404);
15080 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15081 let mut source = test_source_record(server.url());
15082 source.last_update = Some(Utc::now() - Duration::days(2));
15083 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15084 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15085
15086 let error = repository
15087 .show(&show_test_package(Some("3.0.0")))
15088 .expect_err("still-missing explicit version must fail after one stale refresh");
15089
15090 assert!(format!("{error:#}").contains("3.0.0"));
15091 assert_eq!(server.request_count("/source.msix"), 1);
15092 let _ = fs::remove_dir_all(app_root);
15093 }
15094
15095 #[test]
15096 fn select_v2_version_without_explicit_filters_returns_latest() {
15097 let versions = vec![
15098 version_entry("1.0.0", None, None),
15099 version_entry("1.2.0", None, None),
15100 version_entry("1.1.0", None, None),
15101 ];
15102
15103 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15104
15105 assert_eq!(selected.version, "1.2.0");
15106 }
15107
15108 #[test]
15109 fn select_v2_version_with_missing_requested_version_throws() {
15110 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15111
15112 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15113 .expect_err("missing requested version");
15114
15115 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15116 assert!(error.to_string().contains("9.9.9"));
15117 }
15118
15119 #[test]
15120 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15121 let versions = vec![version_entry("1.2.0", None, None)];
15122
15123 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15124 .expect_err("unsupported channel request");
15125
15126 assert!(error.to_string().contains("beta"));
15127 assert!(error.to_string().contains("channel metadata"));
15128 }
15129
15130 #[test]
15131 fn select_v1_version_channel_only_returns_latest_matching_channel() {
15132 let versions = vec![
15133 v1_version("1.2.0", "stable"),
15134 v1_version("1.3.0", "stable"),
15135 v1_version("2.0.0", "preview"),
15136 ];
15137
15138 let selected =
15139 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15140
15141 assert_eq!(selected.version, "1.3.0");
15142 }
15143
15144 #[test]
15145 fn select_rest_version_channel_mismatch_throws() {
15146 let versions = vec![
15147 VersionKey {
15148 version: "1.2.0".to_owned(),
15149 channel: "stable".to_owned(),
15150 },
15151 VersionKey {
15152 version: "1.1.0".to_owned(),
15153 channel: "beta".to_owned(),
15154 },
15155 ];
15156
15157 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15158 .expect_err("missing version and channel pair");
15159
15160 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15161 }
15162
15163 #[test]
15164 fn map_arp_version_returns_catalog_version_inside_range() {
15165 let entries = vec![
15170 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15171 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15172 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15173 ];
15174
15175 assert_eq!(
15176 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15177 Some("10.0.108")
15178 );
15179 }
15180
15181 #[test]
15182 fn map_arp_version_returns_none_when_no_range_matches() {
15183 let entries = vec![
15188 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15189 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15190 ];
15191
15192 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15193 }
15194
15195 #[test]
15196 fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15197 let entries = vec![
15201 version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15202 version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15203 ];
15204
15205 assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15206 assert_eq!(
15207 less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15208 Some("< 25.4.1")
15209 );
15210 assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15211 }
15212
15213 #[test]
15214 fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15215 let entries = vec![
15219 version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15220 version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15221 ];
15222
15223 assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15224 assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15225 assert_eq!(
15226 greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15227 Some("> 1.5.8")
15228 );
15229 assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15230
15231 let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15232 assert!(
15233 greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15234 );
15235 }
15236
15237 #[test]
15238 fn map_arp_version_skips_entries_missing_arp_bounds() {
15239 let entries = vec![
15243 version_entry("9.0.300", None, None),
15244 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15245 ];
15246
15247 assert_eq!(
15248 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15249 Some("9.0.117")
15250 );
15251 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15252 }
15253
15254 #[test]
15255 fn map_arp_version_handles_inclusive_range_endpoints() {
15256 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15259
15260 assert_eq!(
15261 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15262 Some("3.14.5")
15263 );
15264 assert_eq!(
15265 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15266 Some("3.14.5")
15267 );
15268 assert_eq!(
15269 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15270 Some("3.14.5")
15271 );
15272 }
15273
15274 #[test]
15275 fn map_arp_version_ignores_unknown_marker() {
15276 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15280
15281 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15282 assert!(map_arp_version_to_catalog(&entries, "").is_none());
15283 }
15284
15285 #[test]
15286 fn latest_arp_anchored_skips_internal_versions() {
15287 let entries = vec![
15293 version_entry("8000.836.2153.0", None, None),
15294 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15295 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15296 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15297 ];
15298
15299 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15300 }
15301
15302 #[test]
15303 fn latest_arp_anchored_returns_none_without_bounds() {
15304 let entries = vec![
15308 version_entry("1.28.240.0", None, None),
15309 version_entry("1.27.470.0", None, None),
15310 ];
15311
15312 assert!(latest_arp_anchored_version(&entries).is_none());
15313 }
15314
15315 #[test]
15316 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15317 let package = InstalledPackage {
15318 name: "AzCopy v10".to_owned(),
15319 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15320 installed_version: "10.32.2".to_owned(),
15321 publisher: Some("Microsoft".to_owned()),
15322 scope: Some("Machine".to_owned()),
15323 installer_category: Some("exe".to_owned()),
15324 install_location: None,
15325 package_family_names: Vec::new(),
15326 product_codes: Vec::new(),
15327 upgrade_codes: Vec::new(),
15328 correlated: Some(SearchMatch {
15329 source_name: "winget".to_owned(),
15330 source_kind: SourceKind::PreIndexed,
15331 id: "Microsoft.Azure.AZCopy.10".to_owned(),
15332 name: "AzCopy v10".to_owned(),
15333 moniker: None,
15334 version: Some("10.32.3".to_owned()),
15335 channel: None,
15336 match_criteria: None,
15337 }),
15338 winget_package_identifier: None,
15339 winget_source_identifier: None,
15340 installed_version_canonical: false,
15341 correlated_requires_explicit_upgrade: false,
15342 correlated_lacks_compatible_installer: false,
15343 };
15344
15345 let item = list_match_from_installed(package, &[]);
15346
15347 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15348 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15349 assert_eq!(item.source_name.as_deref(), Some("winget"));
15350 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15351 }
15352
15353 #[test]
15354 fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15355 let rows = vec![
15356 ListMatch {
15357 name: "Example 1.0".to_owned(),
15358 id: "Example.Tool".to_owned(),
15359 local_id: r"ARP\Machine\X64\old".to_owned(),
15360 installed_version: "1.0.0".to_owned(),
15361 available_version: Some("3.0.0".to_owned()),
15362 source_name: Some("winget".to_owned()),
15363 publisher: None,
15364 scope: None,
15365 installer_category: None,
15366 install_location: None,
15367 package_family_names: Vec::new(),
15368 product_codes: Vec::new(),
15369 upgrade_codes: Vec::new(),
15370 },
15371 ListMatch {
15372 name: "Example 2.0".to_owned(),
15373 id: "Example.Tool".to_owned(),
15374 local_id: r"ARP\Machine\X64\new".to_owned(),
15375 installed_version: "2.0.0".to_owned(),
15376 available_version: Some("3.0.0".to_owned()),
15377 source_name: Some("winget".to_owned()),
15378 publisher: None,
15379 scope: None,
15380 installer_category: None,
15381 install_location: None,
15382 package_family_names: Vec::new(),
15383 product_codes: Vec::new(),
15384 upgrade_codes: Vec::new(),
15385 },
15386 ListMatch {
15387 name: "Other".to_owned(),
15388 id: "Other.Tool".to_owned(),
15389 local_id: r"ARP\Machine\X64\other".to_owned(),
15390 installed_version: "1.0.0".to_owned(),
15391 available_version: Some("2.0.0".to_owned()),
15392 source_name: Some("winget".to_owned()),
15393 publisher: None,
15394 scope: None,
15395 installer_category: None,
15396 install_location: None,
15397 package_family_names: Vec::new(),
15398 product_codes: Vec::new(),
15399 upgrade_codes: Vec::new(),
15400 },
15401 ];
15402
15403 let result = suppress_duplicate_available_versions(rows);
15404
15405 assert_eq!(result[0].available_version, None);
15406 assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15407 assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15408 }
15409
15410 #[test]
15411 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15412 let package = InstalledPackage {
15413 name: "Atlassian CLI".to_owned(),
15414 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15415 installed_version: "1.3.18-stable".to_owned(),
15416 publisher: Some("Atlassian".to_owned()),
15417 scope: Some("User".to_owned()),
15418 installer_category: Some("exe".to_owned()),
15419 install_location: None,
15420 package_family_names: Vec::new(),
15421 product_codes: Vec::new(),
15422 upgrade_codes: Vec::new(),
15423 correlated: Some(SearchMatch {
15424 source_name: "winget".to_owned(),
15425 source_kind: SourceKind::PreIndexed,
15426 id: "Atlassian.AtlassianCLI".to_owned(),
15427 name: "Atlassian CLI".to_owned(),
15428 moniker: None,
15429 version: Some("1.3.18-stable".to_owned()),
15430 channel: None,
15431 match_criteria: Some("ProductCode".to_owned()),
15432 }),
15433 winget_package_identifier: None,
15434 winget_source_identifier: None,
15435 installed_version_canonical: false,
15436 correlated_requires_explicit_upgrade: false,
15437 correlated_lacks_compatible_installer: false,
15438 };
15439
15440 let item = list_match_from_installed(package, &[]);
15441
15442 assert_eq!(item.id, "Atlassian.AtlassianCLI");
15443 assert_eq!(
15444 item.local_id,
15445 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15446 );
15447 assert_ne!(item.id, item.local_id);
15448 assert_eq!(item.source_name.as_deref(), Some("winget"));
15449 }
15450
15451 #[test]
15452 fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15453 let package = InstalledPackage {
15454 name: "FFmpeg".to_owned(),
15455 local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15456 installed_version: "8.0".to_owned(),
15457 publisher: Some("Gyan".to_owned()),
15458 scope: Some("User".to_owned()),
15459 installer_category: Some("portable".to_owned()),
15460 install_location: None,
15461 package_family_names: Vec::new(),
15462 product_codes: Vec::new(),
15463 upgrade_codes: Vec::new(),
15464 correlated: None,
15465 winget_package_identifier: None,
15466 winget_source_identifier: None,
15467 installed_version_canonical: false,
15468 correlated_requires_explicit_upgrade: false,
15469 correlated_lacks_compatible_installer: false,
15470 };
15471 let sources = vec![SourceRecord {
15472 name: "winget".to_owned(),
15473 kind: SourceKind::PreIndexed,
15474 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15475 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15476 trust_level: "Trusted".to_owned(),
15477 explicit: false,
15478 priority: 0,
15479 last_update: None,
15480 source_version: None,
15481 etag: None,
15482 last_modified: None,
15483 }];
15484
15485 let item = list_match_from_installed(package, &sources);
15486
15487 assert_eq!(item.id, "Gyan.FFmpeg");
15488 assert_eq!(
15489 item.local_id,
15490 r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15491 );
15492 assert_eq!(item.source_name.as_deref(), Some("winget"));
15493 }
15494
15495 #[test]
15496 fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15497 let package = InstalledPackage {
15498 name: "tessl".to_owned(),
15499 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15500 installed_version: "0.77.0".to_owned(),
15501 publisher: Some("tessl.io".to_owned()),
15502 scope: Some("User".to_owned()),
15503 installer_category: Some("portable".to_owned()),
15504 install_location: None,
15505 package_family_names: Vec::new(),
15506 product_codes: Vec::new(),
15507 upgrade_codes: Vec::new(),
15508 correlated: None,
15509 winget_package_identifier: Some("tessl.tessl".to_owned()),
15510 winget_source_identifier: Some("api.winget.pro".to_owned()),
15511 installed_version_canonical: false,
15512 correlated_requires_explicit_upgrade: false,
15513 correlated_lacks_compatible_installer: false,
15514 };
15515
15516 let item = list_match_from_installed(package, &[]);
15517
15518 assert_eq!(item.id, "tessl.tessl");
15519 assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15520 }
15521
15522 #[test]
15523 fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15524 let package = InstalledPackage {
15525 name: "tessl".to_owned(),
15526 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15527 installed_version: "0.77.0".to_owned(),
15528 publisher: Some("tessl.io".to_owned()),
15529 scope: Some("User".to_owned()),
15530 installer_category: Some("portable".to_owned()),
15531 install_location: None,
15532 package_family_names: Vec::new(),
15533 product_codes: Vec::new(),
15534 upgrade_codes: Vec::new(),
15535 correlated: None,
15536 winget_package_identifier: Some("tessl.tessl".to_owned()),
15537 winget_source_identifier: Some("api.winget.pro".to_owned()),
15538 installed_version_canonical: false,
15539 correlated_requires_explicit_upgrade: false,
15540 correlated_lacks_compatible_installer: false,
15541 };
15542 let sources = vec![SourceRecord {
15543 name: "winget.pro".to_owned(),
15544 kind: SourceKind::Rest,
15545 arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
15546 identifier: "api.winget.pro".to_owned(),
15547 trust_level: "Trusted".to_owned(),
15548 explicit: false,
15549 priority: 0,
15550 last_update: None,
15551 source_version: None,
15552 etag: None,
15553 last_modified: None,
15554 }];
15555
15556 let item = list_match_from_installed(package, &sources);
15557
15558 assert_eq!(item.id, "tessl.tessl");
15559 assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
15560 }
15561
15562 #[test]
15563 fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
15564 let package = InstalledPackage {
15565 name: "Example".to_owned(),
15566 local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
15567 installed_version: "1.0".to_owned(),
15568 publisher: Some("Vendor".to_owned()),
15569 scope: Some("User".to_owned()),
15570 installer_category: Some("portable".to_owned()),
15571 install_location: None,
15572 package_family_names: Vec::new(),
15573 product_codes: Vec::new(),
15574 upgrade_codes: Vec::new(),
15575 correlated: None,
15576 winget_package_identifier: None,
15577 winget_source_identifier: None,
15578 installed_version_canonical: false,
15579 correlated_requires_explicit_upgrade: false,
15580 correlated_lacks_compatible_installer: false,
15581 };
15582 let sources = vec![
15583 SourceRecord {
15584 name: "long".to_owned(),
15585 kind: SourceKind::PreIndexed,
15586 arg: "https://example.invalid/long".to_owned(),
15587 identifier: "identifier-longer-than-local-id".to_owned(),
15588 trust_level: "Trusted".to_owned(),
15589 explicit: false,
15590 priority: 0,
15591 last_update: None,
15592 source_version: None,
15593 etag: None,
15594 last_modified: None,
15595 },
15596 SourceRecord {
15597 name: "short".to_owned(),
15598 kind: SourceKind::PreIndexed,
15599 arg: "https://example.invalid/short".to_owned(),
15600 identifier: "short".to_owned(),
15601 trust_level: "Trusted".to_owned(),
15602 explicit: false,
15603 priority: 1,
15604 last_update: None,
15605 source_version: None,
15606 etag: None,
15607 last_modified: None,
15608 },
15609 ];
15610
15611 let item = list_match_from_installed(package, &sources);
15612
15613 assert_eq!(item.id, "Vendor.Example");
15614 assert_eq!(item.source_name.as_deref(), Some("short"));
15615 }
15616
15617 #[test]
15618 fn list_response_serialization_keeps_pascal_case_fields() {
15619 let response = ListResponse {
15620 matches: vec![ListMatch {
15621 name: "PowerToys".to_owned(),
15622 id: "Microsoft.PowerToys".to_owned(),
15623 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
15624 installed_version: "0.98.1".to_owned(),
15625 available_version: Some("0.99.0".to_owned()),
15626 source_name: Some("winget".to_owned()),
15627 publisher: Some("Microsoft".to_owned()),
15628 scope: Some("Machine".to_owned()),
15629 installer_category: Some("msi".to_owned()),
15630 install_location: None,
15631 package_family_names: Vec::new(),
15632 product_codes: Vec::new(),
15633 upgrade_codes: Vec::new(),
15634 }],
15635 warnings: Vec::new(),
15636 truncated: false,
15637 };
15638
15639 let value = serde_json::to_value(&response).expect("serialize list response");
15640 let match_value = &value["Matches"][0];
15641
15642 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
15643 assert_eq!(match_value["InstalledVersion"], "0.98.1");
15644 assert_eq!(match_value["AvailableVersion"], "0.99.0");
15645 assert_eq!(match_value["SourceName"], "winget");
15646 assert!(match_value.get("local_id").is_none());
15647 assert!(match_value.get("installed_version").is_none());
15648 assert!(match_value.get("available_version").is_none());
15649 assert!(match_value.get("source_name").is_none());
15650 }
15651
15652 #[test]
15653 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
15654 let response = SearchResponse {
15655 matches: vec![SearchMatch {
15656 source_name: "msstore".to_owned(),
15657 source_kind: SourceKind::Rest,
15658 id: "9WZDNCRFJBMP".to_owned(),
15659 name: "Microsoft To Do".to_owned(),
15660 moniker: None,
15661 version: Some("2.123.456.0".to_owned()),
15662 channel: None,
15663 match_criteria: None,
15664 }],
15665 warnings: Vec::new(),
15666 truncated: false,
15667 };
15668
15669 let value = serde_json::to_value(&response).expect("serialize search response");
15670 let match_value = &value["Matches"][0];
15671
15672 assert_eq!(match_value["Name"], "Microsoft To Do");
15673 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
15674 assert_eq!(match_value["Version"], "2.123.456.0");
15675 assert_eq!(match_value["SourceName"], "msstore");
15676 assert!(match_value.get("source_name").is_none());
15677 }
15678
15679 #[test]
15680 fn versions_result_serialization_keeps_pascal_case_fields() {
15681 let result = VersionsResult {
15682 package: SearchMatch {
15683 source_name: "winget".to_owned(),
15684 source_kind: SourceKind::PreIndexed,
15685 id: "Microsoft.PowerToys".to_owned(),
15686 name: "PowerToys".to_owned(),
15687 moniker: None,
15688 version: Some("0.99.0".to_owned()),
15689 channel: None,
15690 match_criteria: None,
15691 },
15692 versions: vec![
15693 VersionKey {
15694 version: "0.99.0".to_owned(),
15695 channel: "stable".to_owned(),
15696 },
15697 VersionKey {
15698 version: "0.100.0-preview".to_owned(),
15699 channel: "preview".to_owned(),
15700 },
15701 ],
15702 warnings: Vec::new(),
15703 };
15704
15705 let value = serde_json::to_value(&result).expect("serialize versions result");
15706
15707 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
15708 assert_eq!(value["Versions"][0]["Channel"], "stable");
15709 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
15710 assert_eq!(value["Versions"][1]["Channel"], "preview");
15711 assert!(value.get("versions").is_none());
15712 }
15713
15714 #[test]
15715 fn version_data_parses_arp_bounds_from_winget_payload() {
15716 let payload = "sV: 1.0\nvD:\n- v: 10.0.300\n aMiV: 10.3.26.23102\n aMaV: 10.3.26.23102\n rP: manifests/m/Microsoft/DotNet/SDK/10/10.0.300/4f87\n s256H: 0e633f7fa41d0322ff185d25783951f2ed343f27965b165066d9f75d5689a48b\n- v: 9.0.0\n rP: manifests/m/Microsoft/DotNet/SDK/10/9.0.0/0000\n s256H: 0e633f7fa41d0322ff185d25783951f2ed343f27965b165066d9f75d5689a48b\n";
15720
15721 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
15722 assert_eq!(document.versions.len(), 2);
15723 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
15724 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
15725 assert!(document.versions[1].arp_min_version.is_none());
15726 assert!(document.versions[1].arp_max_version.is_none());
15727 }
15728
15729 #[test]
15730 fn msix_resource_string_name_resolves_to_catalog_name() {
15731 let mut package = InstalledPackage {
15736 name: "ms-resource:appDisplayName".to_owned(),
15737 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
15738 installed_version: "1.28.239.0".to_owned(),
15739 publisher: None,
15740 scope: Some("User".to_owned()),
15741 installer_category: Some("msix".to_owned()),
15742 install_location: None,
15743 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
15744 product_codes: Vec::new(),
15745 upgrade_codes: Vec::new(),
15746 correlated: Some(SearchMatch {
15747 source_name: "winget".to_owned(),
15748 source_kind: SourceKind::PreIndexed,
15749 id: "Microsoft.AppInstaller".to_owned(),
15750 name: "App Installer".to_owned(),
15751 moniker: None,
15752 version: Some("1.28.240.0".to_owned()),
15753 channel: None,
15754 match_criteria: Some("PackageFamilyName".to_owned()),
15755 }),
15756 winget_package_identifier: None,
15757 winget_source_identifier: None,
15758 installed_version_canonical: false,
15759 correlated_requires_explicit_upgrade: false,
15760 correlated_lacks_compatible_installer: false,
15761 };
15762 apply_msix_resource_string_name_fix(&mut package);
15763 assert_eq!(package.name, "App Installer");
15764 }
15765
15766 #[test]
15767 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
15768 let mut package = InstalledPackage {
15773 name: "ms-resource:appDisplayName".to_owned(),
15774 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
15775 installed_version: "1.0".to_owned(),
15776 publisher: None,
15777 scope: Some("Machine".to_owned()),
15778 installer_category: Some("msi".to_owned()),
15779 install_location: None,
15780 package_family_names: Vec::new(),
15781 product_codes: Vec::new(),
15782 upgrade_codes: Vec::new(),
15783 correlated: Some(SearchMatch {
15784 source_name: "winget".to_owned(),
15785 source_kind: SourceKind::PreIndexed,
15786 id: "Some.Package".to_owned(),
15787 name: "Should Not Apply".to_owned(),
15788 moniker: None,
15789 version: Some("1.0".to_owned()),
15790 channel: None,
15791 match_criteria: None,
15792 }),
15793 winget_package_identifier: None,
15794 winget_source_identifier: None,
15795 installed_version_canonical: false,
15796 correlated_requires_explicit_upgrade: false,
15797 correlated_lacks_compatible_installer: false,
15798 };
15799 apply_msix_resource_string_name_fix(&mut package);
15800 assert_eq!(package.name, "ms-resource:appDisplayName");
15801 }
15802
15803 #[test]
15804 fn msix_resource_string_fix_skips_resolved_names() {
15805 let mut package = InstalledPackage {
15810 name: "Microsoft Teams".to_owned(),
15811 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
15812 installed_version: "25290.205.4069.4894".to_owned(),
15813 publisher: None,
15814 scope: Some("User".to_owned()),
15815 installer_category: Some("msix".to_owned()),
15816 install_location: None,
15817 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
15818 product_codes: Vec::new(),
15819 upgrade_codes: Vec::new(),
15820 correlated: Some(SearchMatch {
15821 source_name: "winget".to_owned(),
15822 source_kind: SourceKind::PreIndexed,
15823 id: "Microsoft.Teams".to_owned(),
15824 name: "Microsoft Teams Catalog Name".to_owned(),
15825 moniker: None,
15826 version: Some("26106.1906.4665.7308".to_owned()),
15827 channel: None,
15828 match_criteria: Some("PackageFamilyName".to_owned()),
15829 }),
15830 winget_package_identifier: None,
15831 winget_source_identifier: None,
15832 installed_version_canonical: false,
15833 correlated_requires_explicit_upgrade: false,
15834 correlated_lacks_compatible_installer: false,
15835 };
15836 apply_msix_resource_string_name_fix(&mut package);
15837 assert_eq!(package.name, "Microsoft Teams");
15838 }
15839
15840 #[cfg(windows)]
15841 #[test]
15842 fn unflip_packed_guid_reverses_msi_installer_packing() {
15843 assert_eq!(
15850 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
15851 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
15852 );
15853 assert_eq!(
15856 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
15857 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
15858 );
15859 assert!(unflip_packed_guid("nothex").is_none());
15861 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
15863 }
15864
15865 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
15866 InstalledPackage {
15867 name: format!("{id} install"),
15868 local_id: format!(r"ARP\Machine\X64\{id}"),
15869 installed_version: installed_version.to_owned(),
15870 publisher: None,
15871 scope: Some("Machine".to_owned()),
15872 installer_category: Some("msi".to_owned()),
15873 install_location: None,
15874 package_family_names: Vec::new(),
15875 product_codes: Vec::new(),
15876 upgrade_codes: Vec::new(),
15877 correlated: Some(SearchMatch {
15878 source_name: "winget".to_owned(),
15879 source_kind: SourceKind::PreIndexed,
15880 id: id.to_owned(),
15881 name: id.to_owned(),
15882 moniker: None,
15883 version: Some("99.0.0".to_owned()),
15884 channel: None,
15885 match_criteria: None,
15886 }),
15887 winget_package_identifier: None,
15888 winget_source_identifier: None,
15889 installed_version_canonical: canonical,
15890 correlated_requires_explicit_upgrade: false,
15891 correlated_lacks_compatible_installer: false,
15892 }
15893 }
15894
15895 #[test]
15896 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
15897 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
15904 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
15905
15906 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
15907 assert_eq!(result.len(), 1);
15908 assert_eq!(result[0].installed_version, "10.0.108");
15909 assert!(result[0].installed_version_canonical);
15910 }
15911
15912 #[test]
15913 fn dedupe_keeps_highest_among_canonical_rows() {
15914 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
15915 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
15916
15917 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
15918 assert_eq!(result.len(), 1);
15919 assert_eq!(result[0].installed_version, "1.7.9");
15920 }
15921
15922 #[test]
15923 fn dedupe_leaves_uncorrelated_rows_alone() {
15924 let uncorrelated = InstalledPackage {
15928 name: "Foo".to_owned(),
15929 local_id: r"ARP\Machine\X64\Foo".to_owned(),
15930 installed_version: "1.0".to_owned(),
15931 publisher: None,
15932 scope: Some("Machine".to_owned()),
15933 installer_category: Some("exe".to_owned()),
15934 install_location: None,
15935 package_family_names: Vec::new(),
15936 product_codes: Vec::new(),
15937 upgrade_codes: Vec::new(),
15938 correlated: None,
15939 winget_package_identifier: None,
15940 winget_source_identifier: None,
15941 installed_version_canonical: false,
15942 correlated_requires_explicit_upgrade: false,
15943 correlated_lacks_compatible_installer: false,
15944 };
15945 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
15946 assert_eq!(result.len(), 1);
15947 }
15948}